From 9b90aaa06e74112c2e5649abd929278d2e10bc9b Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:56:52 +0100 Subject: [PATCH] feat: Add edit file tool (#249) * feat: Add edit file tool * feat: Add icons for action buttons --- CMakeLists.txt | 2 + ChatView/CMakeLists.txt | 8 +- ChatView/ChatModel.cpp | 140 +- ChatView/ChatModel.hpp | 13 + ChatView/ChatRootView.cpp | 359 ++++- ChatView/ChatRootView.hpp | 33 + ChatView/ChatSerializer.cpp | 5 + ChatView/ClientInterface.cpp | 18 +- ChatView/ClientInterface.hpp | 1 + ChatView/icons/apply-changes-button.svg | 15 + ChatView/icons/open-in-editor.svg | 17 + ChatView/icons/reject-changes-button.svg | 16 + ChatView/icons/undo-changes-button.svg | 16 + ChatView/qml/FileEditItem.qml | 423 +++++ ChatView/qml/RootItem.qml | 60 +- ChatView/qml/ToolStatusItem.qml | 1 + ChatView/qml/parts/FileEditsActionBar.qml | 161 ++ .../qml/parts/{ErrorToast.qml => Toast.qml} | 27 +- UIControls/qml/QoATextSlider.qml | 5 +- context/ChangesManager.cpp | 1398 +++++++++++++++++ context/ChangesManager.h | 100 +- context/ProjectUtils.cpp | 19 + context/ProjectUtils.hpp | 11 + settings/CMakeLists.txt | 1 + settings/GeneralSettings.cpp | 42 - settings/GeneralSettings.hpp | 6 - settings/SettingsConstants.hpp | 6 +- settings/ToolsSettings.cpp | 157 ++ settings/ToolsSettings.hpp | 51 + tools/BuildProjectTool.cpp | 2 +- tools/CreateNewFileTool.cpp | 3 +- tools/EditFileTool.cpp | 215 +++ tools/EditFileTool.hpp | 48 + tools/FileSearchUtils.cpp | 258 +++ tools/FileSearchUtils.hpp | 152 ++ tools/FindAndReadFileTool.cpp | 230 +-- tools/FindAndReadFileTool.hpp | 35 +- tools/ToolsFactory.cpp | 9 +- tools/ToolsManager.cpp | 13 +- 39 files changed, 3732 insertions(+), 344 deletions(-) create mode 100644 ChatView/icons/apply-changes-button.svg create mode 100644 ChatView/icons/open-in-editor.svg create mode 100644 ChatView/icons/reject-changes-button.svg create mode 100644 ChatView/icons/undo-changes-button.svg create mode 100644 ChatView/qml/FileEditItem.qml create mode 100644 ChatView/qml/parts/FileEditsActionBar.qml rename ChatView/qml/parts/{ErrorToast.qml => Toast.qml} (79%) create mode 100644 settings/ToolsSettings.cpp create mode 100644 settings/ToolsSettings.hpp create mode 100644 tools/EditFileTool.cpp create mode 100644 tools/EditFileTool.hpp create mode 100644 tools/FileSearchUtils.cpp create mode 100644 tools/FileSearchUtils.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a4342d8..dc5bc0a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -121,9 +121,11 @@ add_qtc_plugin(QodeAssist tools/ToolsManager.hpp tools/ToolsManager.cpp tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp + tools/EditFileTool.hpp tools/EditFileTool.cpp tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.cpp + tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp providers/ClaudeMessage.hpp providers/ClaudeMessage.cpp providers/OpenAIMessage.hpp providers/OpenAIMessage.cpp providers/OllamaMessage.hpp providers/OllamaMessage.cpp diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 2135402..95daf43 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -16,9 +16,11 @@ qt_add_qml_module(QodeAssistChatView qml/parts/TopBar.qml qml/parts/BottomBar.qml qml/parts/AttachedFilesPlace.qml - qml/parts/ErrorToast.qml + qml/parts/Toast.qml qml/ToolStatusItem.qml + qml/FileEditItem.qml qml/parts/RulesViewer.qml + qml/parts/FileEditsActionBar.qml RESOURCES icons/attach-file-light.svg @@ -36,6 +38,10 @@ qt_add_qml_module(QodeAssistChatView icons/chat-icon.svg icons/chat-pause-icon.svg icons/rules-icon.svg + icons/open-in-editor.svg + icons/apply-changes-button.svg + icons/undo-changes-button.svg + icons/reject-changes-button.svg SOURCES ChatWidget.hpp ChatWidget.cpp diff --git a/ChatView/ChatModel.cpp b/ChatView/ChatModel.cpp index 5d024d7..e3498f4 100644 --- a/ChatView/ChatModel.cpp +++ b/ChatView/ChatModel.cpp @@ -26,6 +26,7 @@ #include "ChatAssistantSettings.hpp" #include "Logger.hpp" +#include "context/ChangesManager.h" namespace QodeAssist::Chat { @@ -39,6 +40,21 @@ ChatModel::ChatModel(QObject *parent) &Utils::BaseAspect::changed, this, &ChatModel::tokensThresholdChanged); + + connect(&Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditApplied, + this, + &ChatModel::onFileEditApplied); + + connect(&Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditRejected, + this, + &ChatModel::onFileEditRejected); + + connect(&Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditArchived, + this, + &ChatModel::onFileEditArchived); } int ChatModel::rowCount(const QModelIndex &parent) const @@ -106,6 +122,45 @@ void ChatModel::addMessage( newMessage.attachments = attachments; m_messages.append(newMessage); endInsertRows(); + + if (m_loadingFromHistory && role == ChatRole::FileEdit) { + const QString marker = "QODEASSIST_FILE_EDIT:"; + if (content.contains(marker)) { + int markerPos = content.indexOf(marker); + int jsonStart = markerPos + marker.length(); + + if (jsonStart < content.length()) { + QString jsonStr = content.mid(jsonStart); + QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + + if (doc.isObject()) { + QJsonObject editData = doc.object(); + QString editId = editData.value("edit_id").toString(); + QString filePath = editData.value("file").toString(); + QString oldContent = editData.value("old_content").toString(); + QString newContent = editData.value("new_content").toString(); + QString originalStatus = editData.value("status").toString(); + + if (!editId.isEmpty() && !filePath.isEmpty()) { + Context::ChangesManager::instance().addFileEdit( + editId, filePath, oldContent, newContent, false, true); + + editData["status"] = "archived"; + editData["status_message"] = "Loaded from chat history"; + + QString updatedContent = marker + + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); + m_messages.last().content = updatedContent; + + emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); + + LOG_MESSAGE(QString("Registered historical file edit: %1 (original status: %2, now: archived)") + .arg(editId, originalStatus)); + } + } + } + } + } } } @@ -198,7 +253,6 @@ QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) con break; case ChatRole::Tool: case ChatRole::FileEdit: - // Skip Tool and FileEdit messages - they are UI-only continue; default: continue; @@ -326,8 +380,11 @@ void ChatModel::updateToolResult( } else { QJsonObject editData = doc.object(); - // Generate unique edit ID based on timestamp - QString editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch()); + QString editId = editData.value("edit_id").toString(); + + if (editId.isEmpty()) { + editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch()); + } LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId)); @@ -345,4 +402,81 @@ void ChatModel::updateToolResult( } } +void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent) +{ + for (int i = 0; i < m_messages.size(); ++i) { + if (m_messages[i].id == messageId) { + m_messages[i].content = newContent; + emit dataChanged(index(i), index(i)); + LOG_MESSAGE(QString("Updated message content for id: %1").arg(messageId)); + break; + } + } +} + +void ChatModel::setLoadingFromHistory(bool loading) +{ + m_loadingFromHistory = loading; + LOG_MESSAGE(QString("ChatModel loading from history: %1").arg(loading ? "true" : "false")); +} + +bool ChatModel::isLoadingFromHistory() const +{ + return m_loadingFromHistory; +} + +void ChatModel::onFileEditApplied(const QString &editId) +{ + updateFileEditStatus(editId, "applied", "Successfully applied"); +} + +void ChatModel::onFileEditRejected(const QString &editId) +{ + updateFileEditStatus(editId, "rejected", "Rejected by user"); +} + +void ChatModel::onFileEditArchived(const QString &editId) +{ + updateFileEditStatus(editId, "archived", "Archived (from previous conversation turn)"); +} + +void ChatModel::updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage) +{ + const QString marker = "QODEASSIST_FILE_EDIT:"; + + for (int i = 0; i < m_messages.size(); ++i) { + if (m_messages[i].role == ChatRole::FileEdit && m_messages[i].id == editId) { + const QString &content = m_messages[i].content; + + if (content.contains(marker)) { + int markerPos = content.indexOf(marker); + int jsonStart = markerPos + marker.length(); + + if (jsonStart < content.length()) { + QString jsonStr = content.mid(jsonStart); + QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + + if (doc.isObject()) { + QJsonObject editData = doc.object(); + + editData["status"] = status; + editData["status_message"] = statusMessage; + + QString updatedContent = marker + + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); + + m_messages[i].content = updatedContent; + + emit dataChanged(index(i), index(i)); + + LOG_MESSAGE(QString("Updated FileEdit message status: editId=%1, status=%2") + .arg(editId, status)); + break; + } + } + } + } + } +} + } // namespace QodeAssist::Chat diff --git a/ChatView/ChatModel.hpp b/ChatView/ChatModel.hpp index 106c820..24ac2ba 100644 --- a/ChatView/ChatModel.hpp +++ b/ChatView/ChatModel.hpp @@ -83,12 +83,25 @@ public: const QString &toolId, const QString &toolName, const QString &result); + void updateMessageContent(const QString &messageId, const QString &newContent); + + void setLoadingFromHistory(bool loading); + bool isLoadingFromHistory() const; + signals: void tokensThresholdChanged(); void modelReseted(); +private slots: + void onFileEditApplied(const QString &editId); + void onFileEditRejected(const QString &editId); + void onFileEditArchived(const QString &editId); + private: + void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage); + QVector m_messages; + bool m_loadingFromHistory = false; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 3cfbfb7..6403d64 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -29,14 +29,17 @@ #include #include #include +#include #include #include #include "ChatAssistantSettings.hpp" #include "ChatSerializer.hpp" #include "GeneralSettings.hpp" +#include "ToolsSettings.hpp" #include "Logger.hpp" #include "ProjectSettings.hpp" +#include "context/ChangesManager.h" #include "context/ContextManager.hpp" #include "context/TokenUtils.hpp" #include "llmcore/RulesLoader.hpp" @@ -78,7 +81,11 @@ ChatRootView::ChatRootView(QQuickItem *parent) this, &ChatRootView::updateInputTokensCount); - connect(m_chatModel, &ChatModel::modelReseted, this, [this]() { setRecentFilePath(QString{}); }); + connect(m_chatModel, &ChatModel::modelReseted, this, [this]() { + setRecentFilePath(QString{}); + m_currentMessageRequestId.clear(); + updateCurrentMessageEditsStats(); + }); connect(this, &ChatRootView::attachmentFilesChanged, &ChatRootView::updateInputTokensCount); connect(this, &ChatRootView::linkedFilesChanged, &ChatRootView::updateInputTokensCount); connect( @@ -138,6 +145,46 @@ ChatRootView::ChatRootView(QQuickItem *parent) m_lastErrorMessage = error; emit lastErrorMessageChanged(); }); + + connect(m_clientInterface, &ClientInterface::requestStarted, this, [this](const QString &requestId) { + if (!m_currentMessageRequestId.isEmpty()) { + LOG_MESSAGE(QString("Clearing previous message requestId: %1").arg(m_currentMessageRequestId)); + } + + m_currentMessageRequestId = requestId; + LOG_MESSAGE(QString("New message request started: %1").arg(requestId)); + updateCurrentMessageEditsStats(); + }); + + connect( + &Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditAdded, + this, + [this](const QString &) { updateCurrentMessageEditsStats(); }); + + connect( + &Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditApplied, + this, + [this](const QString &) { updateCurrentMessageEditsStats(); }); + + connect( + &Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditRejected, + this, + [this](const QString &) { updateCurrentMessageEditsStats(); }); + + connect( + &Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditUndone, + this, + [this](const QString &) { updateCurrentMessageEditsStats(); }); + + connect( + &Context::ChangesManager::instance(), + &Context::ChangesManager::fileEditArchived, + this, + [this](const QString &) { updateCurrentMessageEditsStats(); }); updateInputTokensCount(); refreshRules(); @@ -152,7 +199,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) m_isAgentMode = appSettings.value("QodeAssist/Chat/AgentMode", false).toBool(); connect( - &Settings::generalSettings().useTools, + &Settings::toolsSettings().useTools, &Utils::BaseAspect::changed, this, &ChatRootView::toolsSupportEnabledChanged); @@ -258,7 +305,10 @@ void ChatRootView::loadHistory(const QString &filePath) } else { setRecentFilePath(filePath); } + + m_currentMessageRequestId.clear(); updateInputTokensCount(); + updateCurrentMessageEditsStats(); } void ChatRootView::showSaveDialog() @@ -731,7 +781,310 @@ void ChatRootView::setIsAgentMode(bool newIsAgentMode) bool ChatRootView::toolsSupportEnabled() const { - return Settings::generalSettings().useTools(); + return Settings::toolsSettings().useTools(); +} + +void ChatRootView::applyFileEdit(const QString &editId) +{ + LOG_MESSAGE(QString("Applying file edit: %1").arg(editId)); + if (Context::ChangesManager::instance().applyFileEdit(editId)) { + m_lastInfoMessage = QString("File edit applied successfully"); + emit lastInfoMessageChanged(); + + updateFileEditStatus(editId, "applied"); + } else { + auto edit = Context::ChangesManager::instance().getFileEdit(editId); + m_lastErrorMessage = edit.statusMessage.isEmpty() + ? QString("Failed to apply file edit") + : QString("Failed to apply file edit: %1").arg(edit.statusMessage); + emit lastErrorMessageChanged(); + } +} + +void ChatRootView::rejectFileEdit(const QString &editId) +{ + LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId)); + if (Context::ChangesManager::instance().rejectFileEdit(editId)) { + m_lastInfoMessage = QString("File edit rejected"); + emit lastInfoMessageChanged(); + + updateFileEditStatus(editId, "rejected"); + } else { + auto edit = Context::ChangesManager::instance().getFileEdit(editId); + m_lastErrorMessage = edit.statusMessage.isEmpty() + ? QString("Failed to reject file edit") + : QString("Failed to reject file edit: %1").arg(edit.statusMessage); + emit lastErrorMessageChanged(); + } +} + +void ChatRootView::undoFileEdit(const QString &editId) +{ + LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId)); + if (Context::ChangesManager::instance().undoFileEdit(editId)) { + m_lastInfoMessage = QString("File edit undone successfully"); + emit lastInfoMessageChanged(); + + updateFileEditStatus(editId, "rejected"); + } else { + auto edit = Context::ChangesManager::instance().getFileEdit(editId); + m_lastErrorMessage = edit.statusMessage.isEmpty() + ? QString("Failed to undo file edit") + : QString("Failed to undo file edit: %1").arg(edit.statusMessage); + emit lastErrorMessageChanged(); + } +} + +void ChatRootView::openFileEditInEditor(const QString &editId) +{ + LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId)); + + auto edit = Context::ChangesManager::instance().getFileEdit(editId); + if (edit.editId.isEmpty()) { + m_lastErrorMessage = QString("File edit not found: %1").arg(editId); + emit lastErrorMessageChanged(); + return; + } + + Utils::FilePath filePath = Utils::FilePath::fromString(edit.filePath); + + Core::IEditor *editor = Core::EditorManager::openEditor(filePath); + if (!editor) { + m_lastErrorMessage = QString("Failed to open file in editor: %1").arg(edit.filePath); + emit lastErrorMessageChanged(); + return; + } + + auto *textEditor = qobject_cast(editor); + if (textEditor && textEditor->editorWidget()) { + QTextDocument *doc = textEditor->editorWidget()->document(); + if (doc) { + QString currentContent = doc->toPlainText(); + int position = -1; + + if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) { + position = currentContent.indexOf(edit.newContent); + } + else if (!edit.oldContent.isEmpty()) { + position = currentContent.indexOf(edit.oldContent); + } + + if (position >= 0) { + QTextCursor cursor(doc); + cursor.setPosition(position); + textEditor->editorWidget()->setTextCursor(cursor); + textEditor->editorWidget()->centerCursor(); + } + } + } + + LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath)); +} + +void ChatRootView::updateFileEditStatus(const QString &editId, const QString &status) +{ + auto messages = m_chatModel->getChatHistory(); + for (int i = 0; i < messages.size(); ++i) { + if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) { + QString content = messages[i].content; + + const QString marker = "QODEASSIST_FILE_EDIT:"; + int markerPos = content.indexOf(marker); + + QString jsonStr = content; + if (markerPos >= 0) { + jsonStr = content.mid(markerPos + marker.length()); + } + + QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + if (doc.isObject()) { + QJsonObject obj = doc.object(); + obj["status"] = status; + + auto edit = Context::ChangesManager::instance().getFileEdit(editId); + if (!edit.statusMessage.isEmpty()) { + obj["status_message"] = edit.statusMessage; + } + + QString updatedContent = marker + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + m_chatModel->updateMessageContent(editId, updatedContent); + LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status)); + } + break; + } + } + + updateCurrentMessageEditsStats(); +} + +void ChatRootView::applyAllFileEditsForCurrentMessage() +{ + if (m_currentMessageRequestId.isEmpty()) { + m_lastErrorMessage = QString("No active message with file edits"); + emit lastErrorMessageChanged(); + return; + } + + LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentMessageRequestId)); + + QString errorMsg; + bool success = Context::ChangesManager::instance() + .reapplyAllEditsForRequest(m_currentMessageRequestId, &errorMsg); + + if (success) { + m_lastInfoMessage = QString("All file edits applied successfully"); + emit lastInfoMessageChanged(); + + auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId); + for (const auto &edit : edits) { + if (edit.status == Context::ChangesManager::Applied) { + updateFileEditStatus(edit.editId, "applied"); + } + } + } else { + m_lastErrorMessage = errorMsg.isEmpty() + ? QString("Failed to apply some file edits") + : QString("Failed to apply some file edits:\n%1").arg(errorMsg); + emit lastErrorMessageChanged(); + + auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId); + for (const auto &edit : edits) { + if (edit.status == Context::ChangesManager::Applied) { + updateFileEditStatus(edit.editId, "applied"); + } + } + } + + updateCurrentMessageEditsStats(); +} + +void ChatRootView::undoAllFileEditsForCurrentMessage() +{ + if (m_currentMessageRequestId.isEmpty()) { + m_lastErrorMessage = QString("No active message with file edits"); + emit lastErrorMessageChanged(); + return; + } + + LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentMessageRequestId)); + + QString errorMsg; + bool success = Context::ChangesManager::instance() + .undoAllEditsForRequest(m_currentMessageRequestId, &errorMsg); + + if (success) { + m_lastInfoMessage = QString("All file edits undone successfully"); + emit lastInfoMessageChanged(); + + auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId); + for (const auto &edit : edits) { + if (edit.status == Context::ChangesManager::Rejected) { + updateFileEditStatus(edit.editId, "rejected"); + } + } + } else { + m_lastErrorMessage = errorMsg.isEmpty() + ? QString("Failed to undo some file edits") + : QString("Failed to undo some file edits:\n%1").arg(errorMsg); + emit lastErrorMessageChanged(); + + auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId); + for (const auto &edit : edits) { + if (edit.status == Context::ChangesManager::Rejected) { + updateFileEditStatus(edit.editId, "rejected"); + } + } + } + + updateCurrentMessageEditsStats(); +} + +void ChatRootView::updateCurrentMessageEditsStats() +{ + if (m_currentMessageRequestId.isEmpty()) { + if (m_currentMessageTotalEdits != 0 || m_currentMessageAppliedEdits != 0 || + m_currentMessagePendingEdits != 0 || m_currentMessageRejectedEdits != 0) { + m_currentMessageTotalEdits = 0; + m_currentMessageAppliedEdits = 0; + m_currentMessagePendingEdits = 0; + m_currentMessageRejectedEdits = 0; + emit currentMessageEditsStatsChanged(); + } + return; + } + + auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId); + + int total = edits.size(); + int applied = 0; + int pending = 0; + int rejected = 0; + + for (const auto &edit : edits) { + switch (edit.status) { + case Context::ChangesManager::Applied: + applied++; + break; + case Context::ChangesManager::Pending: + pending++; + break; + case Context::ChangesManager::Rejected: + rejected++; + break; + case Context::ChangesManager::Archived: + total--; + break; + } + } + + bool changed = false; + if (m_currentMessageTotalEdits != total) { + m_currentMessageTotalEdits = total; + changed = true; + } + if (m_currentMessageAppliedEdits != applied) { + m_currentMessageAppliedEdits = applied; + changed = true; + } + if (m_currentMessagePendingEdits != pending) { + m_currentMessagePendingEdits = pending; + changed = true; + } + if (m_currentMessageRejectedEdits != rejected) { + m_currentMessageRejectedEdits = rejected; + changed = true; + } + + if (changed) { + LOG_MESSAGE(QString("Updated message edits stats: total=%1, applied=%2, pending=%3, rejected=%4") + .arg(total).arg(applied).arg(pending).arg(rejected)); + emit currentMessageEditsStatsChanged(); + } +} + +int ChatRootView::currentMessageTotalEdits() const +{ + return m_currentMessageTotalEdits; +} + +int ChatRootView::currentMessageAppliedEdits() const +{ + return m_currentMessageAppliedEdits; +} + +int ChatRootView::currentMessagePendingEdits() const +{ + return m_currentMessagePendingEdits; +} + +int ChatRootView::currentMessageRejectedEdits() const +{ + return m_currentMessageRejectedEdits; +} + +QString ChatRootView::lastInfoMessage() const +{ + return m_lastInfoMessage; } } // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index f6c3042..fc2c3f6 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -45,11 +45,17 @@ class ChatRootView : public QQuickItem Q_PROPERTY(int textFormat READ textFormat NOTIFY textFormatChanged FINAL) Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL) Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL) + Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL) Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL) Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) Q_PROPERTY(bool isAgentMode READ isAgentMode WRITE setIsAgentMode NOTIFY isAgentModeChanged FINAL) Q_PROPERTY( bool toolsSupportEnabled READ toolsSupportEnabled NOTIFY toolsSupportEnabledChanged FINAL) + + Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL) + Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL) + Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL) + Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL) QML_ELEMENT @@ -114,6 +120,23 @@ public: void setIsAgentMode(bool newIsAgentMode); bool toolsSupportEnabled() const; + Q_INVOKABLE void applyFileEdit(const QString &editId); + Q_INVOKABLE void rejectFileEdit(const QString &editId); + Q_INVOKABLE void undoFileEdit(const QString &editId); + Q_INVOKABLE void openFileEditInEditor(const QString &editId); + + // Mass file edit operations for current message + Q_INVOKABLE void applyAllFileEditsForCurrentMessage(); + Q_INVOKABLE void undoAllFileEditsForCurrentMessage(); + Q_INVOKABLE void updateCurrentMessageEditsStats(); + + int currentMessageTotalEdits() const; + int currentMessageAppliedEdits() const; + int currentMessagePendingEdits() const; + int currentMessageRejectedEdits() const; + + QString lastInfoMessage() const; + public slots: void sendMessage(const QString &message); void copyToClipboard(const QString &text); @@ -138,13 +161,16 @@ signals: void isRequestInProgressChanged(); void lastErrorMessageChanged(); + void lastInfoMessageChanged(); void activeRulesChanged(); void activeRulesCountChanged(); void isAgentModeChanged(); void toolsSupportEnabledChanged(); + void currentMessageEditsStatsChanged(); private: + void updateFileEditStatus(const QString &editId, const QString &status); QString getChatsHistoryDir() const; QString getSuggestedFileName() const; @@ -163,6 +189,13 @@ private: QString m_lastErrorMessage; QVariantList m_activeRules; bool m_isAgentMode; + + QString m_currentMessageRequestId; + int m_currentMessageTotalEdits{0}; + int m_currentMessageAppliedEdits{0}; + int m_currentMessagePendingEdits{0}; + int m_currentMessageRejectedEdits{0}; + QString m_lastInfoMessage; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatSerializer.cpp b/ChatView/ChatSerializer.cpp index 920df2d..75f5b03 100644 --- a/ChatView/ChatSerializer.cpp +++ b/ChatView/ChatSerializer.cpp @@ -120,9 +120,14 @@ bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json) } model->clear(); + + model->setLoadingFromHistory(true); + for (const auto &message : messages) { model->addMessage(message.content, message.role, message.id); } + + model->setLoadingFromHistory(false); return true; } diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index f87e911..60a7b7f 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -37,9 +37,11 @@ #include "ChatAssistantSettings.hpp" #include "GeneralSettings.hpp" +#include "ToolsSettings.hpp" #include "Logger.hpp" #include "ProvidersManager.hpp" #include "RequestConfig.hpp" +#include #include namespace QodeAssist::Chat { @@ -65,6 +67,8 @@ void ClientInterface::sendMessage( { cancelRequest(); m_accumulatedResponses.clear(); + + Context::ChangesManager::instance().archiveAllNonArchivedEdits(); auto attachFiles = m_contextManager->getContentFiles(attachments); m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles); @@ -89,7 +93,7 @@ void ClientInterface::sendMessage( LLMCore::ContextData context; - const bool isToolsEnabled = Settings::generalSettings().useTools() && useAgentMode; + const bool isToolsEnabled = Settings::toolsSettings().useTools() && useAgentMode; if (chatAssistantSettings.useSystemPrompt()) { QString systemPrompt = chatAssistantSettings.systemPrompt(); @@ -155,6 +159,8 @@ void ClientInterface::sendMessage( QJsonObject request{{"id", requestId}}; m_activeRequests[requestId] = {request, provider}; + + emit requestStarted(requestId); connect( provider, @@ -312,6 +318,16 @@ void ClientInterface::handleFullResponse(const QString &requestId, const QString const RequestContext &ctx = it.value(); QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId]; + + QString applyError; + bool applySuccess = Context::ChangesManager::instance() + .applyPendingEditsForRequest(requestId, &applyError); + + if (!applySuccess) { + LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2") + .arg(requestId, applyError)); + } + handleLLMResponse(finalText, ctx.originalRequest, true); m_activeRequests.erase(it); diff --git a/ChatView/ClientInterface.hpp b/ChatView/ClientInterface.hpp index a8a4e63..9de0e0e 100644 --- a/ChatView/ClientInterface.hpp +++ b/ChatView/ClientInterface.hpp @@ -52,6 +52,7 @@ public: signals: void errorOccurred(const QString &error); void messageReceivedCompletely(); + void requestStarted(const QString &requestId); private slots: void handlePartialResponse(const QString &requestId, const QString &partialText); diff --git a/ChatView/icons/apply-changes-button.svg b/ChatView/icons/apply-changes-button.svg new file mode 100644 index 0000000..7ab159a --- /dev/null +++ b/ChatView/icons/apply-changes-button.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ChatView/icons/open-in-editor.svg b/ChatView/icons/open-in-editor.svg new file mode 100644 index 0000000..d7dd273 --- /dev/null +++ b/ChatView/icons/open-in-editor.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/ChatView/icons/reject-changes-button.svg b/ChatView/icons/reject-changes-button.svg new file mode 100644 index 0000000..a9f0cb4 --- /dev/null +++ b/ChatView/icons/reject-changes-button.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/ChatView/icons/undo-changes-button.svg b/ChatView/icons/undo-changes-button.svg new file mode 100644 index 0000000..b23493b --- /dev/null +++ b/ChatView/icons/undo-changes-button.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/ChatView/qml/FileEditItem.qml b/ChatView/qml/FileEditItem.qml new file mode 100644 index 0000000..85b7d19 --- /dev/null +++ b/ChatView/qml/FileEditItem.qml @@ -0,0 +1,423 @@ +/* + * 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 UIControls + +Rectangle { + id: root + + property string editContent: "" + + readonly property var editData: parseEditData(editContent) + readonly property string filePath: editData.file || "" + readonly property string fileName: getFileName(filePath) + readonly property string editStatus: editData.status || "pending" + readonly property string statusMessage: editData.status_message || "" + readonly property string oldContent: editData.old_content || "" + readonly property string newContent: editData.new_content || "" + + signal applyEdit(string editId) + signal rejectEdit(string editId) + signal undoEdit(string editId) + signal openInEditor(string editId) + + readonly property int borderRadius: 4 + readonly property int contentMargin: 10 + readonly property int contentBottomPadding: 20 + readonly property int headerPadding: 8 + readonly property int statusIndicatorWidth: 4 + + readonly property bool isPending: editStatus === "pending" + readonly property bool isApplied: editStatus === "applied" + readonly property bool isRejected: editStatus === "rejected" + readonly property bool isArchived: editStatus === "archived" + + readonly property color appliedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8) + readonly property color revertedColor: Qt.rgba(0.8, 0.6, 0.2, 0.8) + readonly property color rejectedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8) + readonly property color archivedColor: Qt.rgba(0.5, 0.5, 0.5, 0.8) + readonly property color pendingColor: palette.highlight + + readonly property color appliedBgColor: Qt.rgba(0.2, 0.8, 0.2, 0.3) + readonly property color revertedBgColor: Qt.rgba(0.8, 0.6, 0.2, 0.3) + readonly property color rejectedBgColor: Qt.rgba(0.8, 0.2, 0.2, 0.3) + readonly property color archivedBgColor: Qt.rgba(0.5, 0.5, 0.5, 0.3) + + readonly property string codeFontFamily: { + switch (Qt.platform.os) { + case "windows": return "Consolas" + case "osx": return "Menlo" + case "linux": return "DejaVu Sans Mono" + default: return "monospace" + } + } + readonly property int codeFontSize: Qt.application.font.pointSize + + readonly property color statusColor: { + if (isArchived) return archivedColor + if (isApplied) return appliedColor + if (isRejected) return rejectedColor + return pendingColor + } + + readonly property color statusBgColor: { + if (isArchived) return archivedBgColor + if (isApplied) return appliedBgColor + if (isRejected) return rejectedBgColor + return palette.button + } + + readonly property string statusText: { + if (isArchived) return qsTr("ARCHIVED") + if (isApplied) return qsTr("APPLIED") + if (isRejected) return qsTr("REJECTED") + return qsTr("PENDING") + } + + readonly property int addedLines: countLines(newContent) + readonly property int removedLines: countLines(oldContent) + + function parseEditData(content) { + try { + const marker = "QODEASSIST_FILE_EDIT:"; + let jsonStr = content; + if (content.indexOf(marker) >= 0) { + jsonStr = content.substring(content.indexOf(marker) + marker.length); + } + return JSON.parse(jsonStr); + } catch (e) { + return { + edit_id: "", + file: "", + old_content: "", + new_content: "", + status: "error", + status_message: "" + }; + } + } + + function getFileName(path) { + if (!path) return ""; + const parts = path.split('/'); + return parts[parts.length - 1]; + } + + function countLines(text) { + if (!text) return 0; + return text.split('\n').length; + } + + implicitHeight: fileEditView.implicitHeight + + Rectangle { + id: fileEditView + + property bool expanded: false + + anchors.fill: parent + implicitHeight: expanded ? headerArea.height + contentColumn.implicitHeight + root.contentBottomPadding + root.contentMargin * 2 + : headerArea.height + radius: root.borderRadius + + color: palette.base + + border.width: 1 + border.color: root.isPending + ? (color.hslLightness > 0.5 ? Qt.darker(color, 1.3) : Qt.lighter(color, 1.3)) + : Qt.alpha(root.statusColor, 0.6) + + clip: true + + Behavior on implicitHeight { + NumberAnimation { + duration: 200 + easing.type: Easing.InOutQuad + } + } + + states: [ + State { + name: "expanded" + when: fileEditView.expanded + PropertyChanges { target: contentColumn; opacity: 1 } + }, + State { + name: "collapsed" + when: !fileEditView.expanded + PropertyChanges { target: contentColumn; opacity: 0 } + } + ] + + transitions: Transition { + NumberAnimation { + properties: "opacity" + duration: 200 + easing.type: Easing.InOutQuad + } + } + + MouseArea { + id: headerArea + width: parent.width + height: headerRow.height + 16 + cursorShape: Qt.PointingHandCursor + onClicked: fileEditView.expanded = !fileEditView.expanded + + RowLayout { + id: headerRow + + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: actionButtons.left + leftMargin: root.contentMargin + rightMargin: root.contentMargin + } + spacing: root.headerPadding + + Rectangle { + width: root.statusIndicatorWidth + height: headerText.height + radius: 2 + color: root.statusColor + } + + Text { + id: headerText + Layout.fillWidth: true + text: { + var modeText = root.oldContent.length > 0 ? qsTr("Replace") : qsTr("Append") + if (root.oldContent.length > 0) { + return qsTr("%1: %2 (+%3 -%4)") + .arg(modeText) + .arg(root.fileName) + .arg(root.addedLines) + .arg(root.removedLines) + } else { + return qsTr("%1: %2 (+%3)") + .arg(modeText) + .arg(root.fileName) + .arg(root.addedLines) + } + } + font.pixelSize: 12 + font.bold: true + color: palette.text + elide: Text.ElideMiddle + } + + Text { + text: fileEditView.expanded ? "▼" : "▶" + font.pixelSize: 10 + color: palette.mid + } + + Rectangle { + visible: !root.isPending + Layout.preferredWidth: badgeText.width + 12 + Layout.preferredHeight: badgeText.height + 4 + color: root.statusBgColor + radius: 3 + + Text { + id: badgeText + anchors.centerIn: parent + text: root.statusText + font.pixelSize: 9 + font.bold: true + color: root.isArchived ? Qt.rgba(0.6, 0.6, 0.6, 1.0) : palette.text + } + } + } + + Row { + id: actionButtons + + anchors { + right: parent.right + rightMargin: 5 + verticalCenter: parent.verticalCenter + } + spacing: 6 + + QoAButton { + icon { + source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg" + height: 15 + width: 15 + } + hoverEnabled: true + onClicked: root.openInEditor(editData.edit_id) + + ToolTip.visible: hovered + ToolTip.text: qsTr("Open file in editor and navigate to changes") + ToolTip.delay: 500 + } + + QoAButton { + icon { + source: "qrc:/qt/qml/ChatView/icons/apply-changes-button.svg" + height: 15 + width: 15 + } enabled: (root.isPending || root.isRejected) && !root.isArchived + visible: !root.isApplied && !root.isArchived + onClicked: root.applyEdit(editData.edit_id) + } + + QoAButton { + icon { + source: "qrc:/qt/qml/ChatView/icons/undo-changes-button.svg" + height: 15 + width: 15 + } + enabled: root.isApplied && !root.isArchived + visible: root.isApplied && !root.isArchived + onClicked: root.undoEdit(editData.edit_id) + } + + QoAButton { + icon { + source: "qrc:/qt/qml/ChatView/icons/reject-changes-button.svg" + height: 15 + width: 15 + } + enabled: root.isPending && !root.isArchived + visible: root.isPending && !root.isArchived + onClicked: root.rejectEdit(editData.edit_id) + } + } + } + + ColumnLayout { + id: contentColumn + + anchors { + left: parent.left + right: parent.right + top: headerArea.bottom + margins: root.contentMargin + } + spacing: 8 + visible: opacity > 0 + + Text { + Layout.fillWidth: true + text: root.filePath + font.pixelSize: 10 + color: palette.mid + elide: Text.ElideMiddle + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: oldContentColumn.implicitHeight + 12 + color: Qt.rgba(1, 0.2, 0.2, 0.1) + radius: 4 + border.width: 1 + border.color: Qt.rgba(1, 0.2, 0.2, 0.3) + visible: root.oldContent.length > 0 + + Column { + id: oldContentColumn + width: parent.width + x: 6 + y: 6 + spacing: 4 + + Text { + text: qsTr("- Removed:") + font.pixelSize: 10 + font.bold: true + color: Qt.rgba(1, 0.2, 0.2, 0.9) + } + + TextEdit { + id: oldContentText + width: parent.width - 12 + height: contentHeight + text: root.oldContent + font.family: root.codeFontFamily + font.pixelSize: root.codeFontSize + color: palette.text + wrapMode: TextEdit.Wrap + readOnly: true + selectByMouse: true + selectByKeyboard: true + textFormat: TextEdit.PlainText + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: newContentColumn.implicitHeight + 12 + color: Qt.rgba(0.2, 0.8, 0.2, 0.1) + radius: 4 + border.width: 1 + border.color: Qt.rgba(0.2, 0.8, 0.2, 0.3) + + Column { + id: newContentColumn + width: parent.width + x: 6 + y: 6 + spacing: 4 + + Text { + text: qsTr("+ Added:") + font.pixelSize: 10 + font.bold: true + color: Qt.rgba(0.2, 0.8, 0.2, 0.9) + } + + TextEdit { + id: newContentText + width: parent.width - 12 + height: contentHeight + text: root.newContent + font.family: root.codeFontFamily + font.pixelSize: root.codeFontSize + color: palette.text + wrapMode: TextEdit.Wrap + readOnly: true + selectByMouse: true + selectByKeyboard: true + textFormat: TextEdit.PlainText + } + } + } + + Text { + Layout.fillWidth: true + visible: root.statusMessage.length > 0 + text: root.statusMessage + font.pixelSize: 10 + font.italic: true + color: root.isApplied + ? Qt.rgba(0.2, 0.6, 0.2, 1) + : Qt.rgba(0.8, 0.2, 0.2, 1) + wrapMode: Text.WordWrap + } + } + } +} diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index b273f53..012799f 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -115,7 +115,7 @@ ChatRootView { if (model.roleType === ChatModel.Tool) { return toolMessageComponent } else if (model.roleType === ChatModel.FileEdit) { - return toolMessageComponent + return fileEditMessageComponent } else { return chatItemComponent } @@ -174,6 +174,31 @@ ChatRootView { toolContent: model.content } } + + Component { + id: fileEditMessageComponent + + FileEditItem { + width: parent.width + editContent: model.content + + onApplyEdit: function(editId) { + root.applyFileEdit(editId) + } + + onRejectEdit: function(editId) { + root.rejectFileEdit(editId) + } + + onUndoEdit: function(editId) { + root.undoFileEdit(editId) + } + + onOpenInEditor: function(editId) { + root.openFileEditInEditor(editId) + } + } + } } ScrollView { @@ -280,6 +305,19 @@ ChatRootView { onRemoveFileFromListByIndex: (index) => root.removeFileFromLinkList(index) } + FileEditsActionBar { + id: fileEditsActionBar + + Layout.fillWidth: true + totalEdits: root.currentMessageTotalEdits + appliedEdits: root.currentMessageAppliedEdits + pendingEdits: root.currentMessagePendingEdits + rejectedEdits: root.currentMessageRejectedEdits + + onApplyAllClicked: root.applyAllFileEditsForCurrentMessage() + onUndoAllClicked: root.undoAllFileEditsForCurrentMessage() + } + BottomBar { id: bottomBar @@ -329,9 +367,22 @@ ChatRootView { scrollToBottom() } - ErrorToast { + Toast { id: errorToast z: 1000 + + color: Qt.rgba(0.8, 0.2, 0.2, 0.7) + border.color: Qt.darker(infoToast.color, 1.3) + toastTextColor: "#FFFFFF" + } + + Toast { + id: infoToast + z: 1000 + + color: Qt.rgba(0.2, 0.8, 0.2, 0.7) + border.color: Qt.darker(infoToast.color, 1.3) + toastTextColor: "#FFFFFF" } RulesViewer { @@ -356,6 +407,11 @@ ChatRootView { errorToast.show(root.lastErrorMessage) } } + function onLastInfoMessageChanged() { + if (root.lastInfoMessage.length > 0) { + infoToast.show(root.lastInfoMessage) + } + } } Component.onCompleted: { diff --git a/ChatView/qml/ToolStatusItem.qml b/ChatView/qml/ToolStatusItem.qml index 30738ce..f5d022f 100644 --- a/ChatView/qml/ToolStatusItem.qml +++ b/ChatView/qml/ToolStatusItem.qml @@ -87,6 +87,7 @@ Rectangle { TextEdit { id: resultText + width: parent.width text: root.toolResult readOnly: true selectByMouse: true diff --git a/ChatView/qml/parts/FileEditsActionBar.qml b/ChatView/qml/parts/FileEditsActionBar.qml new file mode 100644 index 0000000..6ea69bc --- /dev/null +++ b/ChatView/qml/parts/FileEditsActionBar.qml @@ -0,0 +1,161 @@ +/* + * 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 UIControls + +Rectangle { + id: root + + property int totalEdits: 0 + property int appliedEdits: 0 + property int pendingEdits: 0 + property int rejectedEdits: 0 + property bool hasAppliedEdits: appliedEdits > 0 + property bool hasRejectedEdits: rejectedEdits > 0 + property bool hasPendingEdits: pendingEdits > 0 + + signal applyAllClicked() + signal undoAllClicked() + + visible: totalEdits > 0 + implicitHeight: visible ? 40 : 0 + + color: palette.window.hslLightness > 0.5 ? + Qt.darker(palette.window, 1.05) : + Qt.lighter(palette.window, 1.05) + + border.width: 1 + border.color: palette.mid + + Behavior on implicitHeight { + NumberAnimation { + duration: 200 + easing.type: Easing.InOutQuad + } + } + + RowLayout { + anchors { + left: parent.left + leftMargin: 10 + right: parent.right + rightMargin: 10 + verticalCenter: parent.verticalCenter + } + spacing: 10 + + Rectangle { + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + radius: 12 + color: { + if (root.hasPendingEdits) return Qt.rgba(0.2, 0.6, 1.0, 0.2) + if (root.hasAppliedEdits) return Qt.rgba(0.2, 0.8, 0.2, 0.2) + return Qt.rgba(0.8, 0.6, 0.2, 0.2) + } + border.width: 2 + border.color: { + if (root.hasPendingEdits) return Qt.rgba(0.2, 0.6, 1.0, 0.8) + if (root.hasAppliedEdits) return Qt.rgba(0.2, 0.8, 0.2, 0.8) + return Qt.rgba(0.8, 0.6, 0.2, 0.8) + } + + Text { + anchors.centerIn: parent + text: root.totalEdits + font.pixelSize: 10 + font.bold: true + color: palette.text + } + } + + // Status text + ColumnLayout { + spacing: 2 + + Text { + text: root.totalEdits === 1 + ? qsTr("File Edit in Current Message") + : qsTr("%1 File Edits in Current Message").arg(root.totalEdits) + font.pixelSize: 11 + font.bold: true + color: palette.text + } + + Text { + visible: root.totalEdits > 0 + text: { + let parts = []; + if (root.appliedEdits > 0) { + parts.push(qsTr("%1 applied").arg(root.appliedEdits)); + } + if (root.pendingEdits > 0) { + parts.push(qsTr("%1 pending").arg(root.pendingEdits)); + } + if (root.rejectedEdits > 0) { + parts.push(qsTr("%1 rejected").arg(root.rejectedEdits)); + } + return parts.join(", "); + } + font.pixelSize: 9 + color: palette.mid + } + } + + Item { + Layout.fillWidth: true + } + + QoAButton { + id: applyAllButton + + visible: root.hasPendingEdits || root.hasRejectedEdits + enabled: root.hasPendingEdits || root.hasRejectedEdits + text: root.hasPendingEdits + ? qsTr("Apply All (%1)").arg(root.pendingEdits + root.rejectedEdits) + : qsTr("Reapply All (%1)").arg(root.rejectedEdits) + + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: root.hasPendingEdits + ? qsTr("Apply all pending and rejected edits in this message") + : qsTr("Reapply all rejected edits in this message") + + onClicked: root.applyAllClicked() + } + + QoAButton { + id: undoAllButton + + visible: root.hasAppliedEdits + enabled: root.hasAppliedEdits + text: qsTr("Undo All (%1)").arg(root.appliedEdits) + + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: qsTr("Undo all applied edits in this message") + + onClicked: root.undoAllClicked() + } + } +} + diff --git a/ChatView/qml/parts/ErrorToast.qml b/ChatView/qml/parts/Toast.qml similarity index 79% rename from ChatView/qml/parts/ErrorToast.qml rename to ChatView/qml/parts/Toast.qml index 52f0bba..5f2e0a8 100644 --- a/ChatView/qml/parts/ErrorToast.qml +++ b/ChatView/qml/parts/Toast.qml @@ -20,13 +20,16 @@ import QtQuick Rectangle { - id: errorToast + id: root + + property alias toastTextItem: textItem + property alias toastTextColor: textItem.color property string errorText: "" property int displayDuration: 5000 - width: Math.min(parent.width - 40, errorTextItem.implicitWidth + radius) - height: visible ? (errorTextItem.implicitHeight + 12) : 0 + width: Math.min(parent.width - 40, textItem.implicitWidth + radius) + height: visible ? (textItem.implicitHeight + 12) : 0 anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: 10 @@ -39,15 +42,15 @@ Rectangle { opacity: 0 TextEdit { - id: errorTextItem + id: textItem anchors.centerIn: parent anchors.margins: 6 - text: errorToast.errorText - color: "#ffffff" + text: root.errorText + color: palette.text font.pixelSize: 13 wrapMode: TextEdit.Wrap - width: Math.min(implicitWidth, errorToast.parent.width - 60) + width: Math.min(implicitWidth, root.parent.width - 60) horizontalAlignment: TextEdit.AlignHCenter readOnly: true selectByMouse: true @@ -69,7 +72,7 @@ Rectangle { NumberAnimation { id: showAnimation - target: errorToast + target: root property: "opacity" from: 0 to: 1 @@ -80,21 +83,21 @@ Rectangle { NumberAnimation { id: hideAnimation - target: errorToast + target: root property: "opacity" from: 1 to: 0 duration: 200 easing.type: Easing.InQuad - onFinished: errorToast.visible = false + onFinished: root.visible = false } Timer { id: hideTimer - interval: errorToast.displayDuration + interval: root.displayDuration running: false repeat: false - onTriggered: errorToast.hide() + onTriggered: root.hide() } } diff --git a/UIControls/qml/QoATextSlider.qml b/UIControls/qml/QoATextSlider.qml index 3ac0b5c..4660418 100644 --- a/UIControls/qml/QoATextSlider.qml +++ b/UIControls/qml/QoATextSlider.qml @@ -54,9 +54,10 @@ Item { Rectangle { id: slider - x: root.checked ? parent.width / 2 : 0 + anchors.verticalCenter: parent.verticalCenter + x: root.checked ? parent.width / 2 - 1 : 1 width: parent.width / 2 - height: parent.height + height: parent.height - 2 opacity: 0.6 radius: height / 2 diff --git a/context/ChangesManager.cpp b/context/ChangesManager.cpp index 3ef860b..33ac7fc 100644 --- a/context/ChangesManager.cpp +++ b/context/ChangesManager.cpp @@ -20,6 +20,14 @@ #include "ChangesManager.h" #include "CodeCompletionSettings.hpp" +#include +#include +#include +#include +#include +#include +#include + namespace QodeAssist::Context { ChangesManager &ChangesManager::instance() @@ -30,6 +38,7 @@ ChangesManager &ChangesManager::instance() ChangesManager::ChangesManager() : QObject(nullptr) + , m_undoStack(new QUndoStack(this)) {} ChangesManager::~ChangesManager() {} @@ -75,4 +84,1393 @@ QString ChangesManager::getRecentChangesContext(const TextEditor::TextDocument * return context; } +void ChangesManager::addFileEdit( + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent, + bool autoApply, + bool isFromHistory, + const QString &requestId) +{ + QMutexLocker locker(&m_mutex); + + if (m_fileEdits.contains(editId)) { + LOG_MESSAGE(QString("File edit already exists, skipping: %1").arg(editId)); + return; + } + + FileEdit edit; + edit.editId = editId; + edit.filePath = filePath; + edit.oldContent = oldContent; + edit.newContent = newContent; + edit.timestamp = QDateTime::currentDateTime(); + edit.wasAutoApplied = false; + edit.isFromHistory = isFromHistory; + + LOG_MESSAGE(QString("Creating diff for edit %1").arg(editId)); + locker.unlock(); + edit.diffInfo = createDiffInfo(oldContent, newContent, filePath); + locker.relock(); + LOG_MESSAGE(QString("Diff created for edit %1: %2 hunk(s), fallback: %3") + .arg(editId) + .arg(edit.diffInfo.hunks.size()) + .arg(edit.diffInfo.useFallback ? "yes" : "no")); + + if (isFromHistory) { + edit.status = Archived; + edit.statusMessage = "Loaded from chat history"; + autoApply = false; + } else { + edit.status = Pending; + edit.statusMessage = "Waiting to be applied"; + } + + m_fileEdits.insert(editId, edit); + + if (!requestId.isEmpty() && !isFromHistory) { + if (!m_requestEdits.contains(requestId)) { + m_requestEdits[requestId] = RequestEdits(); + } + m_requestEdits[requestId].editIds.append(editId); + + LOG_MESSAGE(QString("File edit tracked for request: %1 (requestId: %2)") + .arg(editId, requestId)); + } + + emit fileEditAdded(editId); + + LOG_MESSAGE(QString("File edit added: %1 for file %2 (history: %3, autoApply: %4)") + .arg(editId, filePath, isFromHistory ? "yes" : "no", autoApply ? "yes" : "no")); + + if (autoApply && !edit.wasAutoApplied && !isFromHistory) { + locker.unlock(); + if (applyFileEdit(editId)) { + QMutexLocker relock(&m_mutex); + m_fileEdits[editId].wasAutoApplied = true; + LOG_MESSAGE(QString("File edit auto-applied immediately: %1").arg(editId)); + } + } +} + +bool ChangesManager::applyFileEdit(const QString &editId) +{ + QMutexLocker locker(&m_mutex); + + if (!m_fileEdits.contains(editId)) { + LOG_MESSAGE(QString("File edit not found: %1").arg(editId)); + return false; + } + + FileEdit &edit = m_fileEdits[editId]; + + if (edit.status == Applied) { + LOG_MESSAGE(QString("File edit already applied: %1").arg(editId)); + return true; + } + + if (edit.status == Archived) { + LOG_MESSAGE(QString("Cannot apply archived file edit: %1").arg(editId)); + edit.statusMessage = "Cannot apply archived edit from history"; + return false; + } + + QString filePathCopy = edit.filePath; + QString oldContentCopy = edit.oldContent; + QString newContentCopy = edit.newContent; + + locker.unlock(); + + LOG_MESSAGE(QString("Applying edit %1 using fragment replacement").arg(editId)); + + QString errorMsg; + bool isAppend = oldContentCopy.isEmpty(); + bool success = performFragmentReplacement( + filePathCopy, oldContentCopy, newContentCopy, isAppend, &errorMsg); + + locker.relock(); + + if (success) { + edit.status = Applied; + edit.statusMessage = errorMsg.isEmpty() ? "Successfully applied" : errorMsg; + + locker.unlock(); + emit fileEditApplied(editId); + + LOG_MESSAGE(QString("File edit applied successfully: %1").arg(editId)); + return true; + } else { + edit.statusMessage = errorMsg.isEmpty() ? "Failed to apply" : errorMsg; + LOG_MESSAGE(QString("File edit failed: %1 - %2").arg(editId, edit.statusMessage)); + } + + return false; +} + +bool ChangesManager::rejectFileEdit(const QString &editId) +{ + QMutexLocker locker(&m_mutex); + + if (!m_fileEdits.contains(editId)) { + LOG_MESSAGE(QString("File edit not found: %1").arg(editId)); + return false; + } + + FileEdit &edit = m_fileEdits[editId]; + + if (edit.status == Archived) { + LOG_MESSAGE(QString("Cannot reject archived file edit: %1").arg(editId)); + edit.statusMessage = "Cannot reject archived edit from history"; + return false; + } + + edit.status = Rejected; + edit.statusMessage = "Rejected by user"; + + locker.unlock(); + emit fileEditRejected(editId); + + LOG_MESSAGE(QString("File edit rejected: %1").arg(editId)); + return true; +} + +bool ChangesManager::undoFileEdit(const QString &editId) +{ + QMutexLocker locker(&m_mutex); + + if (!m_fileEdits.contains(editId)) { + LOG_MESSAGE(QString("File edit not found: %1").arg(editId)); + return false; + } + + FileEdit &edit = m_fileEdits[editId]; + + if (edit.status == Archived) { + LOG_MESSAGE(QString("Cannot undo archived file edit: %1").arg(editId)); + edit.statusMessage = "Cannot undo archived edit from history"; + return false; + } + + if (edit.status != Applied) { + LOG_MESSAGE(QString("File edit is not applied, cannot undo: %1").arg(editId)); + edit.statusMessage = "Edit must be applied before it can be undone"; + return false; + } + + QString filePathCopy = edit.filePath; + QString oldContentCopy = edit.oldContent; + QString newContentCopy = edit.newContent; + + locker.unlock(); + + LOG_MESSAGE(QString("Undoing edit %1 using REVERSE fragment replacement").arg(editId)); + + QString errorMsg; + bool isAppend = oldContentCopy.isEmpty(); + bool success = performFragmentReplacement( + filePathCopy, newContentCopy, oldContentCopy, isAppend, &errorMsg); + + locker.relock(); + + if (success) { + edit.status = Rejected; + edit.statusMessage = errorMsg.isEmpty() ? "Successfully undone" : errorMsg; + edit.wasAutoApplied = false; + + locker.unlock(); + emit fileEditUndone(editId); + + LOG_MESSAGE(QString("File edit undone successfully: %1").arg(editId)); + return true; + } else { + edit.statusMessage = errorMsg.isEmpty() ? "Failed to undo" : errorMsg; + LOG_MESSAGE(QString("File edit undo failed: %1 - %2").arg(editId, edit.statusMessage)); + } + + return false; +} + +ChangesManager::FileEdit ChangesManager::getFileEdit(const QString &editId) const +{ + QMutexLocker locker(&m_mutex); + return m_fileEdits.value(editId); +} + +QList ChangesManager::getPendingEdits() const +{ + QMutexLocker locker(&m_mutex); + + QList pendingEdits; + for (const auto &edit : m_fileEdits.values()) { + if (edit.status == Pending) { + pendingEdits.append(edit); + } + } + return pendingEdits; +} + +bool ChangesManager::performFileEdit( + const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg) +{ + auto setError = [errorMsg](const QString &msg) { + if (errorMsg) *errorMsg = msg; + }; + + auto editors = Core::EditorManager::visibleEditors(); + for (auto *editor : editors) { + if (!editor || !editor->document()) { + continue; + } + + QString editorPath = editor->document()->filePath().toFSPathString(); + if (editorPath == filePath) { + QByteArray contentBytes = editor->document()->contents(); + QString currentContent = QString::fromUtf8(contentBytes); + + if (oldContent.isEmpty()) { + if (auto *textEditor + = qobject_cast(editor->document())) { + QTextDocument *doc = textEditor->document(); + + QTextCursor cursor(doc); + cursor.beginEditBlock(); + cursor.movePosition(QTextCursor::End); + cursor.insertText(newContent); + cursor.endEditBlock(); + + LOG_MESSAGE(QString("Appended to open editor: %1").arg(filePath)); + setError("Applied successfully (appended to end of file)"); + return true; + } + } + + int matchPos = currentContent.indexOf(oldContent); + if (matchPos != -1) { + if (auto *textEditor + = qobject_cast(editor->document())) { + QTextDocument *doc = textEditor->document(); + + QTextCursor cursor(doc); + cursor.beginEditBlock(); + cursor.setPosition(matchPos); + cursor.setPosition(matchPos + oldContent.length(), QTextCursor::KeepAnchor); + cursor.removeSelectedText(); + cursor.insertText(newContent); + cursor.endEditBlock(); + + LOG_MESSAGE(QString("Updated open editor (exact match): %1").arg(filePath)); + setError("Applied successfully (exact match)"); + return true; + } + } else { + double similarity = 0.0; + QString matchedContent = findBestMatch(currentContent, oldContent, 0.8, &similarity); + if (!matchedContent.isEmpty()) { + matchPos = currentContent.indexOf(matchedContent); + if (matchPos != -1) { + if (auto *textEditor + = qobject_cast(editor->document())) { + QTextDocument *doc = textEditor->document(); + + QTextCursor cursor(doc); + cursor.beginEditBlock(); + cursor.setPosition(matchPos); + cursor.setPosition(matchPos + matchedContent.length(), QTextCursor::KeepAnchor); + cursor.removeSelectedText(); + cursor.insertText(newContent); + cursor.endEditBlock(); + + LOG_MESSAGE(QString("Updated open editor (fuzzy match %1%%): %2") + .arg(qRound(similarity * 100)).arg(filePath)); + setError(QString("Applied with fuzzy match (%1%% similarity)").arg(qRound(similarity * 100))); + return true; + } + } + } + + LOG_MESSAGE(QString("Old content not found in open editor (best similarity: %1%%): %2") + .arg(qRound(similarity * 100)).arg(filePath)); + setError(QString("Content not found. Best match: %1%% (threshold: 80%%). " + "File may have changed.").arg(qRound(similarity * 100))); + return false; + } + } + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QString msg = QString("Cannot open file: %1").arg(file.errorString()); + LOG_MESSAGE(QString("Failed to open file for reading: %1 - %2").arg(filePath, file.errorString())); + setError(msg); + return false; + } + + QString currentContent = QString::fromUtf8(file.readAll()); + file.close(); + + QString updatedContent; + + if (oldContent.isEmpty()) { + updatedContent = currentContent + newContent; + LOG_MESSAGE(QString("Appending to file: %1").arg(filePath)); + setError("Applied successfully (appended to end of file)"); + } + else if (currentContent.contains(oldContent)) { + updatedContent = currentContent.replace(oldContent, newContent); + LOG_MESSAGE(QString("Using exact match for file update: %1").arg(filePath)); + setError("Applied successfully (exact match)"); + } else { + double similarity = 0.0; + QString matchedContent = findBestMatch(currentContent, oldContent, 0.8, &similarity); + if (!matchedContent.isEmpty()) { + updatedContent = currentContent.replace(matchedContent, newContent); + LOG_MESSAGE(QString("Using fuzzy match (%1%%) for file update: %2") + .arg(qRound(similarity * 100)).arg(filePath)); + setError(QString("Applied with fuzzy match (%1%% similarity)").arg(qRound(similarity * 100))); + } else { + QString msg = QString("Content not found. Best match: %1%% (threshold: 80%%). " + "File may have changed.").arg(qRound(similarity * 100)); + LOG_MESSAGE(QString("Old content not found in file (best similarity: %1%%): %2") + .arg(qRound(similarity * 100)).arg(filePath)); + setError(msg); + return false; + } + } + + if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + QString msg = QString("Cannot write file: %1").arg(file.errorString()); + LOG_MESSAGE(QString("Failed to open file for writing: %1 - %2").arg(filePath, file.errorString())); + setError(msg); + return false; + } + + QTextStream out(&file); + out << updatedContent; + file.close(); + + LOG_MESSAGE(QString("File updated: %1").arg(filePath)); + return true; +} + +int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) const +{ + const int len1 = s1.length(); + const int len2 = s2.length(); + + QVector> d(len1 + 1, QVector(len2 + 1)); + + for (int i = 0; i <= len1; ++i) { + d[i][0] = i; + } + for (int j = 0; j <= len2; ++j) { + d[0][j] = j; + } + + for (int i = 1; i <= len1; ++i) { + for (int j = 1; j <= len2; ++j) { + int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; + d[i][j] = std::min({ + d[i - 1][j] + 1, + d[i][j - 1] + 1, + d[i - 1][j - 1] + cost + }); + } + } + + return d[len1][len2]; +} + +QString ChangesManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const +{ + if (searchContent.isEmpty() || fileContent.isEmpty()) { + if (outSimilarity) *outSimilarity = 0.0; + return QString(); + } + + const int searchLen = searchContent.length(); + const int fileLen = fileContent.length(); + + if (searchLen > fileLen) { + if (outSimilarity) *outSimilarity = 0.0; + return QString(); + } + + QString bestMatch; + double bestSimilarity = 0.0; + + for (int i = 0; i <= fileLen - searchLen; ++i) { + QString candidate = fileContent.mid(i, searchLen); + int distance = levenshteinDistance(candidate, searchContent); + double similarity = 1.0 - (static_cast(distance) / searchLen); + + if (similarity > bestSimilarity) { + bestSimilarity = similarity; + if (similarity >= threshold) { + bestMatch = candidate; + } + } + } + + if (outSimilarity) { + *outSimilarity = bestSimilarity; + } + + if (!bestMatch.isEmpty()) { + LOG_MESSAGE(QString("Fuzzy match found with similarity: %1%%").arg(qRound(bestSimilarity * 100))); + } else { + LOG_MESSAGE(QString("No match found above threshold. Best similarity: %1%%").arg(qRound(bestSimilarity * 100))); + } + + return bestMatch; +} + +QString ChangesManager::findBestMatchWithNormalization( + const QString &fileContent, + const QString &searchContent, + double *outSimilarity, + QString *outMatchType) const +{ + if (searchContent.isEmpty() || fileContent.isEmpty()) { + if (outSimilarity) *outSimilarity = 0.0; + if (outMatchType) *outMatchType = "none"; + return QString(); + } + + if (fileContent.contains(searchContent)) { + LOG_MESSAGE("Match found: Exact match"); + if (outSimilarity) *outSimilarity = 1.0; + if (outMatchType) *outMatchType = "exact"; + return searchContent; + } + + double bestSim = 0.0; + QString bestMatch = findBestMatch(fileContent, searchContent, 0.0, &bestSim); + + if (!bestMatch.isEmpty() && bestSim >= 0.70) { + LOG_MESSAGE(QString("Match found: Fuzzy match (%1%% similarity)") + .arg(qRound(bestSim * 100))); + if (outSimilarity) *outSimilarity = bestSim; + if (outMatchType) { + if (bestSim >= 0.85) { + *outMatchType = "fuzzy_high"; + } else { + *outMatchType = "fuzzy_medium"; + } + } + return bestMatch; + } + + LOG_MESSAGE(QString("Cannot proceed: similarity too low (%1%%). " + "File may have been auto-formatted or manually edited.") + .arg(qRound(bestSim * 100))); + + if (outSimilarity) *outSimilarity = bestSim; + if (outMatchType) *outMatchType = "none"; + + return QString(); +} + +bool ChangesManager::performFragmentReplacement( + const QString &filePath, + const QString &searchContent, + const QString &replaceContent, + bool isAppendOperation, + QString *errorMsg) +{ + QString currentContent = readFileContent(filePath); + if (currentContent.isNull()) { + if (errorMsg) { + *errorMsg = "Failed to read current file content"; + } + LOG_MESSAGE(QString("Failed to read file for fragment replacement: %1").arg(filePath)); + return false; + } + + QString resultContent; + + if (isAppendOperation) { + if (searchContent.isEmpty()) { + resultContent = currentContent + replaceContent; + if (errorMsg) *errorMsg = "Successfully applied"; + } else { + if (currentContent.endsWith(searchContent)) { + resultContent = currentContent.left(currentContent.length() - searchContent.length()); + if (errorMsg) *errorMsg = "Successfully undone"; + } else { + if (errorMsg) { + *errorMsg = "Cannot undo: appended content not found at end of file"; + } + LOG_MESSAGE(QString("Failed to undo append: content not at end: %1").arg(filePath)); + return false; + } + } + } else { + double similarity = 0.0; + QString matchType; + QString matchedContent = findBestMatchWithNormalization( + currentContent, searchContent, &similarity, &matchType); + + if (!matchedContent.isEmpty()) { + resultContent = currentContent; + resultContent.replace(matchedContent, replaceContent); + + if (errorMsg) { + if (matchType == "exact") { + *errorMsg = "Successfully applied"; + } else if (matchType.startsWith("fuzzy")) { + *errorMsg = QString("Applied (%1%% similarity)") + .arg(qRound(similarity * 100)); + } + } + } else { + if (errorMsg) { + *errorMsg = QString("Cannot apply: similarity too low (%1%%). File may have been modified.") + .arg(qRound(similarity * 100)); + } + LOG_MESSAGE(QString("Failed to find content for fragment replacement: %1 (similarity: %2%%)") + .arg(filePath).arg(qRound(similarity * 100))); + return false; + } + } + + DiffInfo freshDiff = createDiffInfo(currentContent, resultContent, filePath); + return performFileEditWithDiff(filePath, freshDiff, false, errorMsg); +} + +bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg) +{ + QMutexLocker locker(&m_mutex); + + if (!m_requestEdits.contains(requestId)) { + LOG_MESSAGE(QString("No edits tracked for request: %1").arg(requestId)); + return true; + } + + const RequestEdits &reqEdits = m_requestEdits[requestId]; + + QStringList notApplied; + for (const QString &editId : reqEdits.editIds) { + if (m_fileEdits.contains(editId)) { + const FileEdit &edit = m_fileEdits[editId]; + if (edit.status == Pending) { + notApplied.append(QString("%1 (pending)").arg(edit.filePath)); + } + } + } + + if (!notApplied.isEmpty()) { + LOG_MESSAGE(QString("Request %1 has %2 edits that were not auto-applied") + .arg(requestId).arg(notApplied.size())); + if (errorMsg) { + *errorMsg = QString("%1 edit(s) were not auto-applied:\n%2") + .arg(notApplied.size()) + .arg(notApplied.join("\n")); + } + return false; + } + + LOG_MESSAGE(QString("All edits for request %1 are applied").arg(requestId)); + return true; +} + +QList ChangesManager::getEditsForRequest(const QString &requestId) const +{ + QMutexLocker locker(&m_mutex); + + QList edits; + + if (!m_requestEdits.contains(requestId)) { + return edits; + } + + const RequestEdits &reqEdits = m_requestEdits[requestId]; + for (const QString &editId : reqEdits.editIds) { + if (m_fileEdits.contains(editId)) { + edits.append(m_fileEdits[editId]); + } + } + + return edits; +} + +bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *errorMsg) +{ + QMutexLocker locker(&m_mutex); + + if (!m_requestEdits.contains(requestId)) { + LOG_MESSAGE(QString("No edits found for request: %1").arg(requestId)); + return true; + } + + const RequestEdits &reqEdits = m_requestEdits[requestId]; + QStringList failedUndos; + int successCount = 0; + + LOG_MESSAGE(QString("Undoing %1 edits for request: %2") + .arg(reqEdits.editIds.size()).arg(requestId)); + + for (int i = reqEdits.editIds.size() - 1; i >= 0; --i) { + const QString &editId = reqEdits.editIds[i]; + + if (!m_fileEdits.contains(editId)) { + LOG_MESSAGE(QString("Edit not found during undo: %1").arg(editId)); + continue; + } + + FileEdit &edit = m_fileEdits[editId]; + + if (edit.status == Archived) { + LOG_MESSAGE(QString("Skipping archived edit: %1").arg(editId)); + continue; + } + + if (edit.status != Applied) { + LOG_MESSAGE(QString("Edit %1 is not applied (status: %2), skipping").arg(editId).arg(edit.status)); + continue; + } + + QString filePathCopy = edit.filePath; + QString oldContentCopy = edit.oldContent; + QString newContentCopy = edit.newContent; + + locker.unlock(); + + LOG_MESSAGE(QString("Undoing edit %1 using REVERSE fragment replacement (mass undo)").arg(editId)); + + QString errMsg; + bool isAppend = oldContentCopy.isEmpty(); + bool success = performFragmentReplacement( + filePathCopy, newContentCopy, oldContentCopy, isAppend, &errMsg); + + locker.relock(); + + if (success) { + edit.status = Rejected; + edit.statusMessage = errMsg.isEmpty() ? "Undone by mass undo" : errMsg; + edit.wasAutoApplied = false; + successCount++; + + locker.unlock(); + emit fileEditUndone(editId); + locker.relock(); + + LOG_MESSAGE(QString("Undone edit %1 for file: %2").arg(editId, edit.filePath)); + } else { + edit.statusMessage = errMsg.isEmpty() ? "Failed to undo" : errMsg; + failedUndos.append(QString("%1: %2").arg(edit.filePath, edit.statusMessage)); + + LOG_MESSAGE(QString("Failed to undo edit %1: %2").arg(editId, edit.statusMessage)); + } + } + + LOG_MESSAGE(QString("Undone %1/%2 edits for request %3") + .arg(successCount).arg(reqEdits.editIds.size()).arg(requestId)); + + if (!failedUndos.isEmpty()) { + if (errorMsg) { + *errorMsg = QString("Failed to undo %1 edit(s):\n%2") + .arg(failedUndos.size()) + .arg(failedUndos.join("\n")); + } + return false; + } + + return true; +} + +bool ChangesManager::reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg) +{ + QMutexLocker locker(&m_mutex); + + if (!m_requestEdits.contains(requestId)) { + LOG_MESSAGE(QString("No edits found for request: %1").arg(requestId)); + return true; + } + + const RequestEdits &reqEdits = m_requestEdits[requestId]; + QStringList failedApplies; + int successCount = 0; + + LOG_MESSAGE(QString("Reapplying %1 edits for request: %2") + .arg(reqEdits.editIds.size()).arg(requestId)); + + for (const QString &editId : reqEdits.editIds) { + if (!m_fileEdits.contains(editId)) { + LOG_MESSAGE(QString("Edit not found during reapply: %1").arg(editId)); + continue; + } + + FileEdit &edit = m_fileEdits[editId]; + + if (edit.status == Archived) { + LOG_MESSAGE(QString("Cannot reapply archived edit: %1").arg(editId)); + continue; + } + + if (edit.status == Applied) { + LOG_MESSAGE(QString("Edit %1 is already applied, skipping").arg(editId)); + successCount++; + continue; + } + + QString filePathCopy = edit.filePath; + QString oldContentCopy = edit.oldContent; + QString newContentCopy = edit.newContent; + + locker.unlock(); + + LOG_MESSAGE(QString("Reapplying edit %1 using fragment replacement (mass apply)").arg(editId)); + + QString errMsg; + bool isAppend = oldContentCopy.isEmpty(); + bool success = performFragmentReplacement( + filePathCopy, oldContentCopy, newContentCopy, isAppend, &errMsg); + + locker.relock(); + + if (success) { + edit.status = Applied; + edit.statusMessage = errMsg.isEmpty() ? "Reapplied successfully" : errMsg; + successCount++; + + locker.unlock(); + emit fileEditApplied(editId); + locker.relock(); + + LOG_MESSAGE(QString("Reapplied edit %1 for file: %2 (%3)") + .arg(editId, edit.filePath, edit.statusMessage)); + } else { + edit.statusMessage = errMsg.isEmpty() ? "Failed to reapply" : errMsg; + failedApplies.append(QString("%1: %2").arg(edit.filePath, edit.statusMessage)); + + LOG_MESSAGE(QString("Failed to reapply edit %1: %2").arg(editId, edit.statusMessage)); + } + } + + LOG_MESSAGE(QString("Reapplied %1/%2 edits for request %3") + .arg(successCount).arg(reqEdits.editIds.size()).arg(requestId)); + + if (!failedApplies.isEmpty()) { + if (errorMsg) { + *errorMsg = QString("Failed to reapply %1 edit(s):\n%2") + .arg(failedApplies.size()) + .arg(failedApplies.join("\n")); + } + return false; + } + + return true; +} + +void ChangesManager::archiveAllNonArchivedEdits() +{ + QMutexLocker locker(&m_mutex); + + QStringList archivedEdits; + + for (auto it = m_fileEdits.begin(); it != m_fileEdits.end(); ++it) { + FileEdit &edit = it.value(); + + if (edit.status != Archived) { + FileEditStatus oldStatus = edit.status; + + edit.status = Archived; + edit.statusMessage = "Archived (from previous conversation turn)"; + archivedEdits.append(edit.editId); + + LOG_MESSAGE(QString("Archived file edit: %1 (file: %2, was: %3)") + .arg(edit.editId, edit.filePath, + oldStatus == Applied ? "applied" : + oldStatus == Rejected ? "rejected" : "pending")); + } + } + + locker.unlock(); + for (const QString &editId : archivedEdits) { + emit fileEditArchived(editId); + } + + if (!archivedEdits.isEmpty()) { + LOG_MESSAGE(QString("Archived %1 file edit(s) from previous conversation turn") + .arg(archivedEdits.size())); + } +} + +QString ChangesManager::readFileContent(const QString &filePath) const +{ + LOG_MESSAGE(QString("Reading current file content: %1").arg(filePath)); + + auto editors = Core::EditorManager::visibleEditors(); + for (auto *editor : editors) { + if (!editor || !editor->document()) { + continue; + } + + QString editorPath = editor->document()->filePath().toFSPathString(); + if (editorPath == filePath) { + QByteArray contentBytes = editor->document()->contents(); + QString content = QString::fromUtf8(contentBytes); + LOG_MESSAGE(QString(" Read from open editor: %1 bytes").arg(content.size())); + return content; + } + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + LOG_MESSAGE(QString(" Failed to read file: %1").arg(file.errorString())); + return QString(); // Return null QString on error + } + + QString content = QString::fromUtf8(file.readAll()); + file.close(); + + LOG_MESSAGE(QString(" Read from disk: %1 bytes").arg(content.size())); + return content; +} + +bool ChangesManager::performFileEditWithDiff( + const QString &filePath, + const DiffInfo &diffInfo, + bool reverse, + QString *errorMsg) +{ + LOG_MESSAGE(QString("=== performFileEditWithDiff: %1 (reverse: %2) ===") + .arg(filePath).arg(reverse ? "yes" : "no")); + + auto setError = [errorMsg](const QString &msg) { + if (errorMsg) *errorMsg = msg; + }; + + auto editors = Core::EditorManager::visibleEditors(); + LOG_MESSAGE(QString(" Checking %1 visible editor(s)").arg(editors.size())); + + for (auto *editor : editors) { + if (!editor || !editor->document()) { + continue; + } + + QString editorPath = editor->document()->filePath().toFSPathString(); + if (editorPath == filePath) { + LOG_MESSAGE(QString(" Found open editor for: %1").arg(filePath)); + + QByteArray contentBytes = editor->document()->contents(); + QString currentContent = QString::fromUtf8(contentBytes); + + LOG_MESSAGE(QString(" Current content size: %1 bytes").arg(currentContent.size())); + + QString modifiedContent = currentContent; + QString diffErrorMsg; + bool diffSuccess = applyDiffToContent(modifiedContent, diffInfo, reverse, &diffErrorMsg); + + if (!diffSuccess) { + LOG_MESSAGE(QString(" Failed to apply diff: %1").arg(diffErrorMsg)); + setError(diffErrorMsg); + + LOG_MESSAGE(" Attempting fallback to old content-based method..."); + QString oldContent = reverse ? diffInfo.modifiedContent : diffInfo.originalContent; + QString newContent = reverse ? diffInfo.originalContent : diffInfo.modifiedContent; + + return performFileEdit(filePath, oldContent, newContent, errorMsg); + } + + if (auto *textEditor = qobject_cast(editor->document())) { + QTextDocument *doc = textEditor->document(); + + LOG_MESSAGE(" Applying changes to text editor document..."); + + if (!doc) { + LOG_MESSAGE(" Document is invalid"); + setError("Document pointer is null"); + return false; + } + + + bool oldBlockState = doc->blockSignals(true); + + try { + QTextCursor cursor(doc); + + if (cursor.isNull()) { + doc->blockSignals(oldBlockState); + LOG_MESSAGE(" Cursor is invalid"); + setError("Cannot create text cursor"); + return false; + } + + cursor.beginEditBlock(); + cursor.select(QTextCursor::Document); + cursor.removeSelectedText(); + cursor.insertText(modifiedContent); + cursor.endEditBlock(); + + doc->blockSignals(oldBlockState); + + emit doc->contentsChange(0, doc->characterCount(), doc->characterCount()); + + LOG_MESSAGE(QString(" ✓ Successfully applied diff to open editor: %1").arg(filePath)); + setError(diffErrorMsg); + return true; + } catch (...) { + doc->blockSignals(oldBlockState); + LOG_MESSAGE(" Exception during document modification"); + setError("Exception during document modification"); + return false; + } + + doc->blockSignals(false); + } + } + } + + LOG_MESSAGE(" File not open in editor, modifying file directly..."); + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QString msg = QString("Cannot open file: %1").arg(file.errorString()); + LOG_MESSAGE(QString(" Failed to open file for reading: %1 - %2") + .arg(filePath, file.errorString())); + setError(msg); + return false; + } + + QString currentContent = QString::fromUtf8(file.readAll()); + file.close(); + + LOG_MESSAGE(QString(" File read successfully (%1 bytes)").arg(currentContent.size())); + + QString modifiedContent = currentContent; + QString diffErrorMsg; + bool diffSuccess = applyDiffToContent(modifiedContent, diffInfo, reverse, &diffErrorMsg); + + if (!diffSuccess) { + LOG_MESSAGE(QString(" Failed to apply diff to file: %1").arg(diffErrorMsg)); + setError(diffErrorMsg); + + LOG_MESSAGE(" Attempting fallback to old content-based method..."); + QString oldContent = reverse ? diffInfo.modifiedContent : diffInfo.originalContent; + QString newContent = reverse ? diffInfo.originalContent : diffInfo.modifiedContent; + + return performFileEdit(filePath, oldContent, newContent, errorMsg); + } + + if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + QString msg = QString("Cannot write file: %1").arg(file.errorString()); + LOG_MESSAGE(QString(" Failed to open file for writing: %1 - %2") + .arg(filePath, file.errorString())); + setError(msg); + return false; + } + + QTextStream out(&file); + out << modifiedContent; + file.close(); + + LOG_MESSAGE(QString(" ✓ Successfully wrote modified content to file: %1").arg(filePath)); + setError(diffErrorMsg); + return true; +} + +ChangesManager::DiffInfo ChangesManager::createDiffInfo( + const QString &originalContent, + const QString &modifiedContent, + const QString &filePath) +{ + LOG_MESSAGE(QString("=== Creating DiffInfo for file: %1 ===").arg(filePath)); + + DiffInfo diffInfo; + diffInfo.originalContent = originalContent; + diffInfo.modifiedContent = modifiedContent; + diffInfo.contextLines = 3; + + QStringList originalLines = originalContent.split('\n'); + QStringList modifiedLines = modifiedContent.split('\n'); + + LOG_MESSAGE(QString(" Original lines: %1, Modified lines: %2") + .arg(originalLines.size()).arg(modifiedLines.size())); + + int origLen = originalLines.size(); + int modLen = modifiedLines.size(); + + QVector> lcs(origLen + 1, QVector(modLen + 1, 0)); + + for (int i = 1; i <= origLen; ++i) { + for (int j = 1; j <= modLen; ++j) { + if (originalLines[i - 1] == modifiedLines[j - 1]) { + lcs[i][j] = lcs[i - 1][j - 1] + 1; + } else { + lcs[i][j] = qMax(lcs[i - 1][j], lcs[i][j - 1]); + } + } + } + + LOG_MESSAGE(QString(" LCS computation complete. LCS length: %1").arg(lcs[origLen][modLen])); + + QList hunks; + DiffHunk currentHunk; + bool inHunk = false; + + int i = origLen; + int j = modLen; + int hunkCount = 0; + + QList> changes; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && originalLines[i - 1] == modifiedLines[j - 1]) { + changes.prepend({i - 1, j - 1}); + i--; + j--; + } else if (j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j])) { + changes.prepend({-1, j - 1}); + j--; + } else if (i > 0) { + changes.prepend({i - 1, -1}); + i--; + } + } + + LOG_MESSAGE(QString(" Backtracking complete. Total operations: %1").arg(changes.size())); + + int idx = 0; + while (idx < changes.size()) { + while (idx < changes.size() && changes[idx].first != -1 && changes[idx].second != -1) { + idx++; + } + + if (idx >= changes.size()) break; + + DiffHunk hunk; + int hunkStartIdx = idx; + + int contextStart = qMax(0, idx - diffInfo.contextLines); + for (int k = contextStart; k < idx; ++k) { + if (changes[k].first != -1) { + hunk.contextBefore.append(originalLines[changes[k].first]); + } + } + + hunk.oldStartLine = changes[hunkStartIdx].first != -1 + ? changes[hunkStartIdx].first + 1 + : (hunkStartIdx > 0 && changes[hunkStartIdx - 1].first != -1 + ? changes[hunkStartIdx - 1].first + 2 + : 1); + hunk.newStartLine = changes[hunkStartIdx].second != -1 + ? changes[hunkStartIdx].second + 1 + : (hunkStartIdx > 0 && changes[hunkStartIdx - 1].second != -1 + ? changes[hunkStartIdx - 1].second + 2 + : 1); + + int lastChangeIdx = idx; + while (idx < changes.size()) { + if (changes[idx].first == -1) { + hunk.addedLines.append(modifiedLines[changes[idx].second]); + lastChangeIdx = idx; + } else if (changes[idx].second == -1) { + hunk.removedLines.append(originalLines[changes[idx].first]); + lastChangeIdx = idx; + } else { + if (idx - lastChangeIdx > diffInfo.contextLines * 2) { + break; + } + } + idx++; + } + + hunk.oldLineCount = hunk.removedLines.size(); + hunk.newLineCount = hunk.addedLines.size(); + + int contextEnd = qMin(changes.size(), idx + diffInfo.contextLines); + for (int k = idx; k < contextEnd; ++k) { + if (changes[k].first != -1) { + hunk.contextAfter.append(originalLines[changes[k].first]); + } + } + + hunks.append(hunk); + hunkCount++; + + LOG_MESSAGE(QString(" Hunk #%1: oldStart=%2, oldCount=%3, newStart=%4, newCount=%5") + .arg(hunkCount) + .arg(hunk.oldStartLine) + .arg(hunk.oldLineCount) + .arg(hunk.newStartLine) + .arg(hunk.newLineCount)); + LOG_MESSAGE(QString(" Context before: %1 lines, Context after: %2 lines") + .arg(hunk.contextBefore.size()) + .arg(hunk.contextAfter.size())); + LOG_MESSAGE(QString(" Removed: %1 lines, Added: %2 lines") + .arg(hunk.removedLines.size()) + .arg(hunk.addedLines.size())); + } + + diffInfo.hunks = hunks; + + if (hunks.isEmpty() && originalContent != modifiedContent) { + LOG_MESSAGE(" WARNING: No hunks created but content differs. Using fallback mode."); + diffInfo.useFallback = true; + } else if (hunks.isEmpty()) { + LOG_MESSAGE(" No changes detected (content identical)."); + } else { + LOG_MESSAGE(QString("=== DiffInfo created successfully with %1 hunk(s) ===").arg(hunks.size())); + } + + return diffInfo; +} + +bool ChangesManager::findHunkLocation( + const QStringList &fileLines, + const DiffHunk &hunk, + int &actualStartLine, + QString *debugInfo) const +{ + LOG_MESSAGE(QString(" Searching for hunk location (expected line: %1)").arg(hunk.oldStartLine)); + + QString debug; + + int expectedIdx = hunk.oldStartLine - 1; + + if (expectedIdx >= 0 && expectedIdx < fileLines.size()) { + bool exactMatch = true; + + int checkIdx = expectedIdx - hunk.contextBefore.size(); + if (checkIdx < 0) { + exactMatch = false; + debug += QString(" Context before out of bounds (need %1 lines before line %2)\n") + .arg(hunk.contextBefore.size()).arg(expectedIdx + 1); + } else { + for (int i = 0; i < hunk.contextBefore.size(); ++i) { + if (fileLines[checkIdx + i] != hunk.contextBefore[i]) { + exactMatch = false; + debug += QString(" Context before mismatch at offset %1: expected '%2', got '%3'\n") + .arg(i).arg(hunk.contextBefore[i]).arg(fileLines[checkIdx + i]); + break; + } + } + } + + if (exactMatch) { + for (int i = 0; i < hunk.removedLines.size(); ++i) { + int lineIdx = expectedIdx + i; + if (lineIdx >= fileLines.size() || fileLines[lineIdx] != hunk.removedLines[i]) { + exactMatch = false; + debug += QString(" Removed line mismatch at offset %1: expected '%2', got '%3'\n") + .arg(i) + .arg(hunk.removedLines[i]) + .arg(lineIdx < fileLines.size() ? fileLines[lineIdx] : ""); + break; + } + } + } + + if (exactMatch && !hunk.contextAfter.isEmpty()) { + int afterIdx = expectedIdx + hunk.removedLines.size(); + for (int i = 0; i < hunk.contextAfter.size(); ++i) { + int lineIdx = afterIdx + i; + if (lineIdx >= fileLines.size() || fileLines[lineIdx] != hunk.contextAfter[i]) { + exactMatch = false; + debug += QString(" Context after mismatch at offset %1: expected '%2', got '%3'\n") + .arg(i) + .arg(hunk.contextAfter[i]) + .arg(lineIdx < fileLines.size() ? fileLines[lineIdx] : ""); + break; + } + } + } + + if (exactMatch) { + actualStartLine = expectedIdx; + LOG_MESSAGE(QString(" ✓ Found exact match at expected line %1").arg(hunk.oldStartLine)); + if (debugInfo) *debugInfo = "Exact match at expected location"; + return true; + } else { + debug += " Exact match at expected location failed, trying fuzzy search...\n"; + } + } else { + debug += QString(" Expected location %1 is out of bounds (file has %2 lines)\n") + .arg(hunk.oldStartLine).arg(fileLines.size()); + } + + LOG_MESSAGE(" Trying fuzzy search within ±20 lines..."); + + int searchStart = qMax(0, expectedIdx - 20); + int searchEnd = qMin(fileLines.size(), expectedIdx + 20); + + int bestMatchLine = -1; + int bestMatchScore = 0; + + for (int searchIdx = searchStart; searchIdx < searchEnd; ++searchIdx) { + int matchScore = 0; + int totalChecks = 0; + + int checkIdx = searchIdx - hunk.contextBefore.size(); + if (checkIdx >= 0) { + for (int i = 0; i < hunk.contextBefore.size(); ++i) { + totalChecks++; + if (fileLines[checkIdx + i] == hunk.contextBefore[i]) { + matchScore++; + } + } + } + + for (int i = 0; i < hunk.removedLines.size(); ++i) { + int lineIdx = searchIdx + i; + if (lineIdx < fileLines.size()) { + totalChecks++; + if (fileLines[lineIdx] == hunk.removedLines[i]) { + matchScore++; + } + } + } + + int afterIdx = searchIdx + hunk.removedLines.size(); + for (int i = 0; i < hunk.contextAfter.size(); ++i) { + int lineIdx = afterIdx + i; + if (lineIdx < fileLines.size()) { + totalChecks++; + if (fileLines[lineIdx] == hunk.contextAfter[i]) { + matchScore++; + } + } + } + + if (matchScore > bestMatchScore) { + bestMatchScore = matchScore; + bestMatchLine = searchIdx; + } + } + + int totalPossibleScore = hunk.contextBefore.size() + hunk.removedLines.size() + hunk.contextAfter.size(); + double matchPercentage = totalPossibleScore > 0 ? (double)bestMatchScore / totalPossibleScore * 100.0 : 0.0; + + if (bestMatchLine != -1 && matchPercentage >= 70.0) { + actualStartLine = bestMatchLine; + debug += QString(" ✓ Found fuzzy match at line %1 (score: %2/%3 = %4%%)\n") + .arg(bestMatchLine + 1) + .arg(bestMatchScore) + .arg(totalPossibleScore) + .arg(matchPercentage, 0, 'f', 1); + LOG_MESSAGE(QString(" ✓ Found fuzzy match at line %1 (%2%% confidence)") + .arg(bestMatchLine + 1).arg(matchPercentage, 0, 'f', 1)); + if (debugInfo) *debugInfo = debug; + return true; + } + + debug += QString(" ✗ No suitable match found (best: %1%% at line %2)\n") + .arg(matchPercentage, 0, 'f', 1) + .arg(bestMatchLine != -1 ? bestMatchLine + 1 : -1); + LOG_MESSAGE(QString(" ✗ Hunk location not found (best match: %1%%)").arg(matchPercentage, 0, 'f', 1)); + + if (debugInfo) *debugInfo = debug; + return false; +} + +bool ChangesManager::applyDiffToContent( + QString &content, + const DiffInfo &diffInfo, + bool reverse, + QString *errorMsg) +{ + LOG_MESSAGE(QString("=== Applying %1 to content ===").arg(reverse ? "REVERSE diff" : "diff")); + + auto setError = [errorMsg](const QString &msg) { + if (errorMsg) *errorMsg = msg; + }; + + if (diffInfo.useFallback) { + LOG_MESSAGE(" Using fallback mode (direct content replacement)"); + + QString searchContent = reverse ? diffInfo.modifiedContent : diffInfo.originalContent; + QString replaceContent = reverse ? diffInfo.originalContent : diffInfo.modifiedContent; + + if (content.contains(searchContent)) { + content.replace(searchContent, replaceContent); + setError("Applied using fallback mode (direct replacement)"); + LOG_MESSAGE(" ✓ Fallback: Direct replacement successful"); + return true; + } else { + setError("Fallback failed: Original content not found in file"); + LOG_MESSAGE(" ✗ Fallback: Content not found"); + return false; + } + } + + if (diffInfo.hunks.isEmpty()) { + LOG_MESSAGE(" No hunks to apply (content unchanged)"); + setError("No changes to apply"); + return true; + } + + QStringList fileLines = content.split('\n'); + LOG_MESSAGE(QString(" File has %1 lines, applying %2 hunk(s)") + .arg(fileLines.size()).arg(diffInfo.hunks.size())); + + QList hunksToApply = diffInfo.hunks; + + std::sort(hunksToApply.begin(), hunksToApply.end(), + [](const DiffHunk &a, const DiffHunk &b) { + return a.oldStartLine > b.oldStartLine; + }); + + LOG_MESSAGE(" Hunks sorted in descending order for application"); + + int appliedHunks = 0; + int failedHunks = 0; + + for (int hunkIdx = 0; hunkIdx < hunksToApply.size(); ++hunkIdx) { + const DiffHunk &hunk = hunksToApply[hunkIdx]; + + LOG_MESSAGE(QString(" --- Applying hunk %1/%2 ---") + .arg(hunkIdx + 1).arg(hunksToApply.size())); + + int actualStartLine = -1; + QString debugInfo; + + if (!findHunkLocation(fileLines, hunk, actualStartLine, &debugInfo)) { + LOG_MESSAGE(QString(" ✗ Failed to locate hunk %1:\n%2") + .arg(hunkIdx + 1).arg(debugInfo)); + failedHunks++; + continue; + } + + LOG_MESSAGE(QString(" Applying hunk at line %1 (remove %2 lines, add %3 lines)") + .arg(actualStartLine + 1) + .arg(hunk.removedLines.size()) + .arg(hunk.addedLines.size())); + + for (int i = 0; i < hunk.removedLines.size(); ++i) { + if (actualStartLine < fileLines.size()) { + LOG_MESSAGE(QString(" Removing line %1: '%2'") + .arg(actualStartLine + 1) + .arg(fileLines[actualStartLine])); + fileLines.removeAt(actualStartLine); + } + } + + for (int i = 0; i < hunk.addedLines.size(); ++i) { + LOG_MESSAGE(QString(" Inserting line %1: '%2'") + .arg(actualStartLine + i + 1) + .arg(hunk.addedLines[i])); + fileLines.insert(actualStartLine + i, hunk.addedLines[i]); + } + + appliedHunks++; + LOG_MESSAGE(QString(" ✓ Hunk %1 applied successfully").arg(hunkIdx + 1)); + } + + if (failedHunks > 0) { + QString msg = QString("Partially applied: %1 of %2 hunks succeeded") + .arg(appliedHunks).arg(hunksToApply.size()); + setError(msg); + LOG_MESSAGE(QString(" ⚠ %1").arg(msg)); + + content = fileLines.join('\n'); + return false; + } + + content = fileLines.join('\n'); + setError(QString("Successfully applied %1 hunk(s)").arg(appliedHunks)); + LOG_MESSAGE(QString("=== All %1 hunk(s) applied successfully ===").arg(appliedHunks)); + return true; +} + } // namespace QodeAssist::Context diff --git a/context/ChangesManager.h b/context/ChangesManager.h index 55e5375..bcb3f2f 100644 --- a/context/ChangesManager.h +++ b/context/ChangesManager.h @@ -22,8 +22,10 @@ #include #include #include +#include #include #include +#include namespace QodeAssist::Context { @@ -39,21 +41,117 @@ public: QString lineContent; }; + enum FileEditStatus { Pending, Applied, Rejected, Archived }; + + struct DiffHunk + { + int oldStartLine; // Starting line in old file (1-based) + int oldLineCount; // Number of lines in old file + int newStartLine; // Starting line in new file (1-based) + int newLineCount; // Number of lines in new file + QStringList contextBefore; // Lines of context before the change (for anchoring) + QStringList removedLines; // Lines to remove (prefixed with -) + QStringList addedLines; // Lines to add (prefixed with +) + QStringList contextAfter; // Lines of context after the change (for anchoring) + }; + + struct DiffInfo + { + QList hunks; // List of diff hunks + QString originalContent; // Full original file content (for fallback) + QString modifiedContent; // Full modified file content (for fallback) + int contextLines = 3; // Number of context lines to keep + bool useFallback = false; // If true, use original content-based approach + }; + + struct FileEdit + { + QString editId; + QString filePath; + QString oldContent; // Kept for backward compatibility and fallback + QString newContent; // Kept for backward compatibility and fallback + DiffInfo diffInfo; // Initial diff (created once, may become stale after formatting) + FileEditStatus status; + QDateTime timestamp; + bool wasAutoApplied = false; // Track if edit was already auto-applied once + bool isFromHistory = false; // Track if edit was loaded from chat history + QString statusMessage; + }; + static ChangesManager &instance(); void addChange( TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded); QString getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const; + void addFileEdit( + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent, + bool autoApply = true, + bool isFromHistory = false, + const QString &requestId = QString()); + bool applyFileEdit(const QString &editId); + bool rejectFileEdit(const QString &editId); + bool undoFileEdit(const QString &editId); + FileEdit getFileEdit(const QString &editId) const; + QList getPendingEdits() const; + + bool applyPendingEditsForRequest(const QString &requestId, QString *errorMsg = nullptr); + + QList getEditsForRequest(const QString &requestId) const; + + bool undoAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr); + + bool reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr); + + void archiveAllNonArchivedEdits(); + +signals: + void fileEditAdded(const QString &editId); + void fileEditApplied(const QString &editId); + void fileEditRejected(const QString &editId); + void fileEditUndone(const QString &editId); + void fileEditArchived(const QString &editId); + private: ChangesManager(); ~ChangesManager(); ChangesManager(const ChangesManager &) = delete; ChangesManager &operator=(const ChangesManager &) = delete; - void cleanupOldChanges(); + bool performFileEdit(const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg = nullptr); + bool performFileEditWithDiff(const QString &filePath, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr); + QString readFileContent(const QString &filePath) const; + + DiffInfo createDiffInfo(const QString &originalContent, const QString &modifiedContent, const QString &filePath); + bool applyDiffToContent(QString &content, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr); + bool findHunkLocation(const QStringList &fileLines, const DiffHunk &hunk, int &actualStartLine, QString *debugInfo = nullptr) const; + + // Helper method for fragment-based apply/undo operations + bool performFragmentReplacement( + const QString &filePath, + const QString &searchContent, + const QString &replaceContent, + bool isAppendOperation, + QString *errorMsg = nullptr); + + int levenshteinDistance(const QString &s1, const QString &s2) const; + QString findBestMatch(const QString &fileContent, const QString &searchContent, double threshold = 0.8, double *outSimilarity = nullptr) const; + QString findBestMatchWithNormalization(const QString &fileContent, const QString &searchContent, double *outSimilarity = nullptr, QString *outMatchType = nullptr) const; + + struct RequestEdits + { + QStringList editIds; + bool autoApplyPending = false; + }; QHash> m_documentChanges; + QHash m_fileEdits; + QHash m_requestEdits; // requestId → ordered edits + QUndoStack *m_undoStack; + mutable QMutex m_mutex; }; } // namespace QodeAssist::Context diff --git a/context/ProjectUtils.cpp b/context/ProjectUtils.cpp index 24ffcbd..6bddd29 100644 --- a/context/ProjectUtils.cpp +++ b/context/ProjectUtils.cpp @@ -51,4 +51,23 @@ bool ProjectUtils::isFileInProject(const QString &filePath) return false; } +QString ProjectUtils::findFileInProject(const QString &filename) +{ + QList projects = ProjectExplorer::ProjectManager::projects(); + + for (auto project : projects) { + if (!project) + continue; + + Utils::FilePaths projectFiles = project->files(ProjectExplorer::Project::SourceFiles); + for (const auto &projectFile : std::as_const(projectFiles)) { + if (projectFile.fileName() == filename) { + return projectFile.toFSPathString(); + } + } + } + + return QString(); +} + } // namespace QodeAssist::Context diff --git a/context/ProjectUtils.hpp b/context/ProjectUtils.hpp index c250fb1..5c32844 100644 --- a/context/ProjectUtils.hpp +++ b/context/ProjectUtils.hpp @@ -41,6 +41,17 @@ public: * @return true if file is part of any open project, false otherwise */ static bool isFileInProject(const QString &filePath); + + /** + * @brief Find a file in open projects by filename + * + * Searches all open projects for a file matching the given filename. + * If multiple files with the same name exist, returns the first match. + * + * @param filename File name to search for (e.g., "main.cpp") + * @return Absolute file path if found, empty string otherwise + */ + static QString findFileInProject(const QString &filename); }; } // namespace QodeAssist::Context diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index be61c3b..7646363 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(QodeAssistSettings STATIC SettingsTr.hpp CodeCompletionSettings.hpp CodeCompletionSettings.cpp ChatAssistantSettings.hpp ChatAssistantSettings.cpp + ToolsSettings.hpp ToolsSettings.cpp SettingsDialog.hpp SettingsDialog.cpp ProjectSettings.hpp ProjectSettings.cpp ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp diff --git a/settings/GeneralSettings.cpp b/settings/GeneralSettings.cpp index b5bea3b..28295c3 100644 --- a/settings/GeneralSettings.cpp +++ b/settings/GeneralSettings.cpp @@ -205,40 +205,6 @@ GeneralSettings::GeneralSettings() caTemplateDescription.setDefaultValue(""); caTemplateDescription.setLabelText(TrConstants::CURRENT_TEMPLATE_DESCRIPTION); - useTools.setSettingsKey(Constants::CA_USE_TOOLS); - useTools.setLabelText(Tr::tr("Enable tools")); - useTools.setToolTip( - Tr::tr( - "Enable tool use capabilities for the assistant(OpenAI function calling, Claude tools " - "and etc) " - "if plugin and provider support")); - useTools.setDefaultValue(true); - - allowFileSystemRead.setSettingsKey(Constants::CA_ALLOW_FILE_SYSTEM_READ); - allowFileSystemRead.setLabelText(Tr::tr("Allow File System Read Access for tools")); - allowFileSystemRead.setToolTip( - Tr::tr("Allow tools to read files from disk (project files, open editors)")); - allowFileSystemRead.setDefaultValue(true); - - allowFileSystemWrite.setSettingsKey(Constants::CA_ALLOW_FILE_SYSTEM_WRITE); - allowFileSystemWrite.setLabelText(Tr::tr("Allow File System Write Access for tools")); - allowFileSystemWrite.setToolTip( - Tr::tr("Allow tools to write and modify files on disk (WARNING: Use with caution!)")); - allowFileSystemWrite.setDefaultValue(false); - - allowAccessOutsideProject.setSettingsKey(Constants::CA_ALLOW_ACCESS_OUTSIDE_PROJECT); - allowAccessOutsideProject.setLabelText(Tr::tr("Allow file access outside project")); - allowAccessOutsideProject.setToolTip( - Tr::tr("Allow tools to access (read/write) files outside the project scope (system headers, Qt files, external libraries)")); - allowAccessOutsideProject.setDefaultValue(true); - - autoApplyFileEdits.setSettingsKey(Constants::CA_AUTO_APPLY_FILE_EDITS); - autoApplyFileEdits.setLabelText(Tr::tr("Automatically apply file edits")); - autoApplyFileEdits.setToolTip( - Tr::tr("When enabled, file edits suggested by AI will be applied automatically. " - "When disabled, you will need to manually approve each edit.")); - autoApplyFileEdits.setDefaultValue(false); - readSettings(); Logger::instance().setLoggingEnabled(enableLogging()); @@ -286,9 +252,6 @@ GeneralSettings::GeneralSettings() title(TrConstants::CHAT_ASSISTANT), Column{ caGrid, - Column{ - useTools, allowFileSystemRead, allowFileSystemWrite, allowAccessOutsideProject, - /*autoApplyFileEdits*/}, caTemplateDescription}}; auto rootLayout = Column{ @@ -543,11 +506,6 @@ void GeneralSettings::resetPageToDefaults() resetAspect(ccPreset1CustomEndpoint); resetAspect(caEndpointMode); resetAspect(caCustomEndpoint); - resetAspect(useTools); - resetAspect(allowFileSystemRead); - resetAspect(allowFileSystemWrite); - resetAspect(allowAccessOutsideProject); - resetAspect(autoApplyFileEdits); writeSettings(); } } diff --git a/settings/GeneralSettings.hpp b/settings/GeneralSettings.hpp index 656ff4a..ebf848b 100644 --- a/settings/GeneralSettings.hpp +++ b/settings/GeneralSettings.hpp @@ -100,12 +100,6 @@ public: Utils::StringAspect caStatus{this}; ButtonAspect caTest{this}; - Utils::BoolAspect useTools{this}; - Utils::BoolAspect allowFileSystemRead{this}; - Utils::BoolAspect allowFileSystemWrite{this}; - Utils::BoolAspect allowAccessOutsideProject{this}; - Utils::BoolAspect autoApplyFileEdits{this}; - Utils::StringAspect caTemplateDescription{this}; void showSelectionDialog( diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 0be5525..8a4f98b 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -89,6 +89,7 @@ const char CA_USE_TOOLS[] = "QodeAssist.caUseTools"; const char CA_ALLOW_FILE_SYSTEM_READ[] = "QodeAssist.caAllowFileSystemRead"; const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite"; const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject"; +const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileTool"; const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions"; const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; @@ -96,13 +97,14 @@ const char QODE_ASSIST_CODE_COMPLETION_SETTINGS_PAGE_ID[] = "QodeAssist.2CodeCompletionSettingsPageId"; const char QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID[] = "QodeAssist.3ChatAssistantSettingsPageId"; -const char QODE_ASSIST_CUSTOM_PROMPT_SETTINGS_PAGE_ID[] = "QodeAssist.4CustomPromptSettingsPageId"; +const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.4ToolsSettingsPageId"; +const char QODE_ASSIST_CUSTOM_PROMPT_SETTINGS_PAGE_ID[] = "QodeAssist.5CustomPromptSettingsPageId"; const char QODE_ASSIST_GENERAL_OPTIONS_CATEGORY[] = "QodeAssist.Category"; const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist"; // Provider Settings Page ID -const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.5ProviderSettingsPageId"; +const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.6ProviderSettingsPageId"; // Provider API Keys const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey"; diff --git a/settings/ToolsSettings.cpp b/settings/ToolsSettings.cpp new file mode 100644 index 0000000..d7a9eb2 --- /dev/null +++ b/settings/ToolsSettings.cpp @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024-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 "ToolsSettings.hpp" + +#include +#include +#include +#include + +#include "SettingsConstants.hpp" +#include "SettingsTr.hpp" +#include "SettingsUtils.hpp" + +namespace QodeAssist::Settings { + +ToolsSettings &toolsSettings() +{ + static ToolsSettings settings; + return settings; +} + +ToolsSettings::ToolsSettings() +{ + setAutoApply(false); + + setDisplayName(Tr::tr("Tools")); + + useTools.setSettingsKey(Constants::CA_USE_TOOLS); + useTools.setLabelText(Tr::tr("Enable tools")); + useTools.setToolTip(Tr::tr( + "Enable tool use capabilities for the assistant (OpenAI function calling, Claude tools " + "and etc) if plugin and provider support")); + useTools.setDefaultValue(true); + + allowFileSystemRead.setSettingsKey(Constants::CA_ALLOW_FILE_SYSTEM_READ); + allowFileSystemRead.setLabelText(Tr::tr("Allow File System Read Access for tools")); + allowFileSystemRead.setToolTip( + Tr::tr("Allow tools to read files from disk (project files, open editors)")); + allowFileSystemRead.setDefaultValue(true); + + allowFileSystemWrite.setSettingsKey(Constants::CA_ALLOW_FILE_SYSTEM_WRITE); + allowFileSystemWrite.setLabelText(Tr::tr("Allow File System Write Access for tools")); + allowFileSystemWrite.setToolTip( + Tr::tr("Allow tools to write and modify files on disk (WARNING: Use with caution!)")); + allowFileSystemWrite.setDefaultValue(false); + + allowAccessOutsideProject.setSettingsKey(Constants::CA_ALLOW_ACCESS_OUTSIDE_PROJECT); + allowAccessOutsideProject.setLabelText(Tr::tr("Allow file access outside project")); + allowAccessOutsideProject.setToolTip( + Tr::tr("Allow tools to access (read/write) files outside the project scope (system " + "headers, Qt files, external libraries)")); + allowAccessOutsideProject.setDefaultValue(true); + + autoApplyFileEdits.setSettingsKey(Constants::CA_AUTO_APPLY_FILE_EDITS); + autoApplyFileEdits.setLabelText(Tr::tr("Automatically apply file edits")); + autoApplyFileEdits.setToolTip( + Tr::tr("When enabled, file edits suggested by AI will be applied automatically. " + "When disabled, you will need to manually approve each edit.")); + autoApplyFileEdits.setDefaultValue(false); + + enableEditFileTool.setSettingsKey(Constants::CA_ENABLE_EDIT_FILE_TOOL); + enableEditFileTool.setLabelText(Tr::tr("Enable Edit File Tool (Experimental)")); + enableEditFileTool.setToolTip( + Tr::tr("Enable the experimental edit_file tool that allows AI to directly modify files. " + "This feature is under testing and may have unexpected behavior.")); + enableEditFileTool.setDefaultValue(false); + + resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults"); + + readSettings(); + + setupConnections(); + + setLayouter([this]() { + using namespace Layouting; + + return Column{ + Row{Stretch{1}, resetToDefaults}, + Space{8}, + Group{ + title(Tr::tr("Tool Settings")), + Column{ + useTools, + Space{8}, + allowFileSystemRead, + allowFileSystemWrite, + allowAccessOutsideProject, + }}, + Space{8}, + Group{ + title(Tr::tr("Experimental Features")), + Column{enableEditFileTool, autoApplyFileEdits}}, + Stretch{1}}; + }); +} + +void ToolsSettings::setupConnections() +{ + connect( + &resetToDefaults, + &ButtonAspect::clicked, + this, + &ToolsSettings::resetSettingsToDefaults); +} + +void ToolsSettings::resetSettingsToDefaults() +{ + QMessageBox::StandardButton reply; + reply = QMessageBox::question( + Core::ICore::dialogParent(), + Tr::tr("Reset Settings"), + Tr::tr("Are you sure you want to reset all settings to default values?"), + QMessageBox::Yes | QMessageBox::No); + + if (reply == QMessageBox::Yes) { + resetAspect(useTools); + resetAspect(allowFileSystemRead); + resetAspect(allowFileSystemWrite); + resetAspect(allowAccessOutsideProject); + resetAspect(autoApplyFileEdits); + resetAspect(enableEditFileTool); + writeSettings(); + } +} + +class ToolsSettingsPage : public Core::IOptionsPage +{ +public: + ToolsSettingsPage() + { + setId(Constants::QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID); + setDisplayName(Tr::tr("Tools")); + setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); + setSettingsProvider([] { return &toolsSettings(); }); + } +}; + +const ToolsSettingsPage toolsSettingsPage; + +} // namespace QodeAssist::Settings diff --git a/settings/ToolsSettings.hpp b/settings/ToolsSettings.hpp new file mode 100644 index 0000000..b15f92d --- /dev/null +++ b/settings/ToolsSettings.hpp @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024-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 "ButtonAspect.hpp" + +namespace QodeAssist::Settings { + +class ToolsSettings : public Utils::AspectContainer +{ +public: + ToolsSettings(); + + ButtonAspect resetToDefaults{this}; + + Utils::BoolAspect useTools{this}; + Utils::BoolAspect allowFileSystemRead{this}; + Utils::BoolAspect allowFileSystemWrite{this}; + Utils::BoolAspect allowAccessOutsideProject{this}; + + // Experimental features + Utils::BoolAspect enableEditFileTool{this}; + Utils::BoolAspect autoApplyFileEdits{this}; + +private: + void setupConnections(); + void resetSettingsToDefaults(); +}; + +ToolsSettings &toolsSettings(); + +} // namespace QodeAssist::Settings diff --git a/tools/BuildProjectTool.cpp b/tools/BuildProjectTool.cpp index 120ae09..a9094af 100644 --- a/tools/BuildProjectTool.cpp +++ b/tools/BuildProjectTool.cpp @@ -49,7 +49,7 @@ QString BuildProjectTool::stringName() const QString BuildProjectTool::description() const { return "Build the current project in Qt Creator. " - "Returns build status and any compilation errors/warnings. " + "No returns simultaneously build status and any compilation errors/warnings. " "Optional 'rebuild' parameter: set to true to force a clean rebuild (default: false)."; } diff --git a/tools/CreateNewFileTool.cpp b/tools/CreateNewFileTool.cpp index 536e7b4..8976b6a 100644 --- a/tools/CreateNewFileTool.cpp +++ b/tools/CreateNewFileTool.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -103,7 +104,7 @@ QFuture CreateNewFileTool::executeAsync(const QJsonObject &input) bool isInProject = Context::ProjectUtils::isFileInProject(absolutePath); if (!isInProject) { - const auto &settings = Settings::generalSettings(); + const auto &settings = Settings::toolsSettings(); if (!settings.allowAccessOutsideProject()) { throw ToolRuntimeError( QString("Error: File path '%1' is not within the current project. " diff --git a/tools/EditFileTool.cpp b/tools/EditFileTool.cpp new file mode 100644 index 0000000..7637212 --- /dev/null +++ b/tools/EditFileTool.cpp @@ -0,0 +1,215 @@ +/* + * 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 "EditFileTool.hpp" +#include "ToolExceptions.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Tools { + +EditFileTool::EditFileTool(QObject *parent) + : BaseTool(parent) + , m_ignoreManager(new Context::IgnoreManager(this)) +{} + +QString EditFileTool::name() const +{ + return "edit_file"; +} + +QString EditFileTool::stringName() const +{ + return {"Editing file"}; +} + +QString EditFileTool::description() const +{ + return "Edit a file by replacing old content with new content. " + "Provide the filename (or absolute path), old_content to find and replace, " + "and new_content to replace it with. Changes are applied immediately if auto-apply " + "is enabled in settings. The user can undo or reapply changes at any time. " + "If old_content is empty, new_content will be appended to the end of the file."; +} + +QJsonObject EditFileTool::getDefinition(LLMCore::ToolSchemaFormat format) const +{ + QJsonObject properties; + + QJsonObject filenameProperty; + filenameProperty["type"] = "string"; + filenameProperty["description"] + = "The filename or absolute path of the file to edit. If only filename is provided, " + "it will be searched in the project"; + properties["filename"] = filenameProperty; + + QJsonObject oldContentProperty; + oldContentProperty["type"] = "string"; + oldContentProperty["description"] + = "The exact content to find and replace. Must match exactly (including whitespace). " + "If empty, new_content will be appended to the end of the file"; + properties["old_content"] = oldContentProperty; + + QJsonObject newContentProperty; + newContentProperty["type"] = "string"; + newContentProperty["description"] = "The new content to replace the old content with"; + properties["new_content"] = newContentProperty; + + QJsonObject definition; + definition["type"] = "object"; + definition["properties"] = properties; + QJsonArray required; + required.append("filename"); + required.append("new_content"); + definition["required"] = required; + + switch (format) { + case LLMCore::ToolSchemaFormat::OpenAI: + return customizeForOpenAI(definition); + case LLMCore::ToolSchemaFormat::Claude: + return customizeForClaude(definition); + case LLMCore::ToolSchemaFormat::Ollama: + return customizeForOllama(definition); + case LLMCore::ToolSchemaFormat::Google: + return customizeForGoogle(definition); + } + + return definition; +} + +LLMCore::ToolPermissions EditFileTool::requiredPermissions() const +{ + return LLMCore::ToolPermission::FileSystemWrite; +} + +QFuture EditFileTool::executeAsync(const QJsonObject &input) +{ + return QtConcurrent::run([this, input]() -> QString { + QString filename = input["filename"].toString().trimmed(); + QString oldContent = input["old_content"].toString(); + QString newContent = input["new_content"].toString(); + QString requestId = input["_request_id"].toString(); + + if (filename.isEmpty()) { + throw ToolInvalidArgument("'filename' parameter is required and cannot be empty"); + } + + if (newContent.isEmpty()) { + throw ToolInvalidArgument("'new_content' parameter is required and cannot be empty"); + } + + + QString filePath; + QFileInfo fileInfo(filename); + + if (fileInfo.isAbsolute() && fileInfo.exists()) { + filePath = filename; + } else { + FileSearchUtils::FileMatch match = FileSearchUtils::findBestMatch( + filename, QString(), 10, m_ignoreManager); + + if (match.absolutePath.isEmpty()) { + throw ToolRuntimeError( + QString("File '%1' not found in project. " + "Please provide a valid filename or absolute path.") + .arg(filename)); + } + + filePath = match.absolutePath; + LOG_MESSAGE(QString("EditFileTool: Found file '%1' at '%2'") + .arg(filename, filePath)); + } + + QFile file(filePath); + if (!file.exists()) { + throw ToolRuntimeError(QString("File does not exist: %1").arg(filePath)); + } + + QFileInfo finalFileInfo(filePath); + if (!finalFileInfo.isWritable()) { + throw ToolRuntimeError( + QString("File is not writable (read-only or permission denied): %1").arg(filePath)); + } + + bool isInProject = Context::ProjectUtils::isFileInProject(filePath); + if (!isInProject) { + const auto &settings = Settings::toolsSettings(); + if (!settings.allowAccessOutsideProject()) { + throw ToolRuntimeError( + QString("File path '%1' is not within the current project. " + "Enable 'Allow file access outside project' in settings to edit files outside the project.") + .arg(filePath)); + } + LOG_MESSAGE(QString("Editing file outside project scope: %1").arg(filePath)); + } + + QString editId = QUuid::createUuid().toString(QUuid::WithoutBraces); + bool autoApply = Settings::toolsSettings().autoApplyFileEdits(); + + Context::ChangesManager::instance().addFileEdit( + editId, + filePath, + oldContent, + newContent, + autoApply, + false, + requestId + ); + + auto edit = Context::ChangesManager::instance().getFileEdit(editId); + QString status = "pending"; + if (edit.status == Context::ChangesManager::Applied) { + status = "applied"; + } else if (edit.status == Context::ChangesManager::Rejected) { + status = "rejected"; + } else if (edit.status == Context::ChangesManager::Archived) { + status = "archived"; + } + + QString statusMessage = edit.statusMessage; + + QJsonObject result; + result["edit_id"] = editId; + result["file"] = filePath; + result["old_content"] = oldContent; + result["new_content"] = newContent; + result["status"] = status; + result["status_message"] = statusMessage; + + LOG_MESSAGE(QString("File edit created: %1 (ID: %2, Status: %3, Deferred: %4)") + .arg(filePath, editId, status, requestId.isEmpty() ? QString("no") : QString("yes"))); + + QString resultStr = "QODEASSIST_FILE_EDIT:" + + QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Compact)); + return resultStr; + }); +} + +} // namespace QodeAssist::Tools + diff --git a/tools/EditFileTool.hpp b/tools/EditFileTool.hpp new file mode 100644 index 0000000..bd81486 --- /dev/null +++ b/tools/EditFileTool.hpp @@ -0,0 +1,48 @@ +/* + * 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 "FileSearchUtils.hpp" + +#include +#include + +namespace QodeAssist::Tools { + +class EditFileTool : public LLMCore::BaseTool +{ + Q_OBJECT +public: + explicit EditFileTool(QObject *parent = nullptr); + + QString name() const override; + QString stringName() const override; + QString description() const override; + QJsonObject getDefinition(LLMCore::ToolSchemaFormat format) const override; + LLMCore::ToolPermissions requiredPermissions() const override; + + QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; + +private: + Context::IgnoreManager *m_ignoreManager; +}; + +} // namespace QodeAssist::Tools + diff --git a/tools/FileSearchUtils.cpp b/tools/FileSearchUtils.cpp new file mode 100644 index 0000000..333544d --- /dev/null +++ b/tools/FileSearchUtils.cpp @@ -0,0 +1,258 @@ +/* + * 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 "FileSearchUtils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Tools { + +FileSearchUtils::FileMatch FileSearchUtils::findBestMatch( + const QString &query, + const QString &filePattern, + int maxResults, + Context::IgnoreManager *ignoreManager) +{ + QList candidates; + auto projects = ProjectExplorer::ProjectManager::projects(); + + if (projects.isEmpty()) { + return FileMatch{}; + } + + QFileInfo queryInfo(query); + if (queryInfo.isAbsolute() && queryInfo.exists() && queryInfo.isFile()) { + FileMatch match; + match.absolutePath = queryInfo.canonicalFilePath(); + + for (auto project : projects) { + if (!project) + continue; + QString projectDir = project->projectDirectory().path(); + if (match.absolutePath.startsWith(projectDir)) { + match.relativePath = QDir(projectDir).relativeFilePath(match.absolutePath); + match.projectName = project->displayName(); + match.matchType = MatchType::ExactName; + return match; + } + } + + match.relativePath = queryInfo.fileName(); + match.projectName = "External"; + match.matchType = MatchType::ExactName; + return match; + } + + QString lowerQuery = query.toLower(); + + for (auto project : projects) { + if (!project) + continue; + + auto projectFiles = project->files(ProjectExplorer::Project::SourceFiles); + QString projectDir = project->projectDirectory().path(); + QString projectName = project->displayName(); + + for (const auto &filePath : projectFiles) { + QString absolutePath = filePath.path(); + + if (ignoreManager && ignoreManager->shouldIgnore(absolutePath, project)) + continue; + + QFileInfo fileInfo(absolutePath); + QString fileName = fileInfo.fileName(); + + if (!filePattern.isEmpty() && !matchesFilePattern(fileName, filePattern)) + continue; + + QString relativePath = QDir(projectDir).relativeFilePath(absolutePath); + + FileMatch match; + match.absolutePath = absolutePath; + match.relativePath = relativePath; + match.projectName = projectName; + + QString lowerFileName = fileName.toLower(); + QString lowerRelativePath = relativePath.toLower(); + + if (lowerFileName == lowerQuery) { + match.matchType = MatchType::ExactName; + candidates.append(match); + } else if (lowerRelativePath.contains(lowerQuery)) { + match.matchType = MatchType::PathMatch; + candidates.append(match); + } else if (lowerFileName.contains(lowerQuery)) { + match.matchType = MatchType::PartialName; + candidates.append(match); + } + } + } + + if (candidates.isEmpty() || candidates.first().matchType != MatchType::ExactName) { + for (auto project : projects) { + if (!project) + continue; + + QString projectDir = project->projectDirectory().path(); + QString projectName = project->displayName(); + int depth = 0; + searchInFileSystem( + projectDir, + lowerQuery, + projectName, + projectDir, + project, + candidates, + maxResults, + depth, + 5, + ignoreManager); + } + } + + if (candidates.isEmpty()) { + return FileMatch{}; + } + + std::sort(candidates.begin(), candidates.end()); + return candidates.first(); +} + +void FileSearchUtils::searchInFileSystem( + const QString &dirPath, + const QString &query, + const QString &projectName, + const QString &projectDir, + ProjectExplorer::Project *project, + QList &matches, + int maxResults, + int ¤tDepth, + int maxDepth, + Context::IgnoreManager *ignoreManager) +{ + if (currentDepth >= maxDepth || matches.size() >= maxResults) + return; + + currentDepth++; + QDir dir(dirPath); + if (!dir.exists()) { + currentDepth--; + return; + } + + auto entries = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto &entry : entries) { + if (matches.size() >= maxResults) + break; + + QString absolutePath = entry.absoluteFilePath(); + + if (ignoreManager && ignoreManager->shouldIgnore(absolutePath, project)) + continue; + + QString fileName = entry.fileName(); + + if (entry.isDir()) { + searchInFileSystem( + absolutePath, + query, + projectName, + projectDir, + project, + matches, + maxResults, + currentDepth, + maxDepth, + ignoreManager); + continue; + } + + QString lowerFileName = fileName.toLower(); + QString relativePath = QDir(projectDir).relativeFilePath(absolutePath); + QString lowerRelativePath = relativePath.toLower(); + + FileMatch match; + match.absolutePath = absolutePath; + match.relativePath = relativePath; + match.projectName = projectName; + + if (lowerFileName == query) { + match.matchType = MatchType::ExactName; + matches.append(match); + } else if (lowerRelativePath.contains(query)) { + match.matchType = MatchType::PathMatch; + matches.append(match); + } else if (lowerFileName.contains(query)) { + match.matchType = MatchType::PartialName; + matches.append(match); + } + } + + currentDepth--; +} + +bool FileSearchUtils::matchesFilePattern(const QString &fileName, const QString &pattern) +{ + if (pattern.isEmpty()) + return true; + + if (pattern.startsWith("*.")) { + QString extension = pattern.mid(1); + return fileName.endsWith(extension, Qt::CaseInsensitive); + } + + return fileName.compare(pattern, Qt::CaseInsensitive) == 0; +} + +QString FileSearchUtils::readFileContent(const QString &filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QString(); + } + + QString canonicalPath = QFileInfo(filePath).canonicalFilePath(); + bool isInProject = Context::ProjectUtils::isFileInProject(canonicalPath); + + if (!isInProject) { + const auto &settings = Settings::toolsSettings(); + if (!settings.allowAccessOutsideProject()) { + LOG_MESSAGE(QString("Access denied to file outside project: %1").arg(canonicalPath)); + return QString(); + } + LOG_MESSAGE(QString("Reading file outside project scope: %1").arg(canonicalPath)); + } + + QTextStream stream(&file); + stream.setAutoDetectUnicode(true); + QString content = stream.readAll(); + + return content; +} + +} // namespace QodeAssist::Tools diff --git a/tools/FileSearchUtils.hpp b/tools/FileSearchUtils.hpp new file mode 100644 index 0000000..6541495 --- /dev/null +++ b/tools/FileSearchUtils.hpp @@ -0,0 +1,152 @@ +/* + * 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 ProjectExplorer { +class Project; +} + +namespace QodeAssist::Tools { + +/** + * @brief Utility class for file searching and reading operations + * + * Provides common functionality for file operations used by various tools: + * - Fuzzy file searching with multiple match strategies + * - File pattern matching (e.g., *.cpp, *.h) + * - Secure file content reading with project boundary checks + * - Integration with IgnoreManager for respecting .qodeassistignore + */ +class FileSearchUtils +{ +public: + /** + * @brief Match quality levels for file search results + */ + enum class MatchType { + ExactName, ///< Exact filename match (highest priority) + PathMatch, ///< Query found in relative path + PartialName ///< Query found in filename (lowest priority) + }; + + /** + * @brief Represents a file search result with metadata + */ + struct FileMatch + { + QString absolutePath; ///< Full absolute path to the file + QString relativePath; ///< Path relative to project root + QString projectName; ///< Name of the project containing the file + QString content; ///< File content (if read) + MatchType matchType; ///< Quality of the match + bool contentRead = false; ///< Whether content has been read + QString error; ///< Error message if operation failed + + /** + * @brief Compare matches by quality (for sorting) + */ + bool operator<(const FileMatch &other) const + { + return static_cast(matchType) < static_cast(other.matchType); + } + }; + + /** + * @brief Find the best matching file across all open projects + * + * Search strategy: + * 1. Check if query is an absolute path + * 2. Search in project source files (exact, path, partial matches) + * 3. Search filesystem within project directories (respects .qodeassistignore) + * + * @param query Filename, partial name, or path to search for (case-insensitive) + * @param filePattern Optional file pattern filter (e.g., "*.cpp", "*.h") + * @param maxResults Maximum number of candidates to collect + * @param ignoreManager IgnoreManager instance for filtering files + * @return Best matching file, or empty FileMatch if not found + */ + static FileMatch findBestMatch( + const QString &query, + const QString &filePattern = QString(), + int maxResults = 10, + Context::IgnoreManager *ignoreManager = nullptr); + + /** + * @brief Check if a filename matches a file pattern + * + * Supports: + * - Wildcard patterns (*.cpp, *.h) + * - Exact filename matching + * - Empty pattern (matches all) + * + * @param fileName File name to check + * @param pattern Pattern to match against + * @return true if filename matches pattern + */ + static bool matchesFilePattern(const QString &fileName, const QString &pattern); + + /** + * @brief Read file content with security checks + * + * Performs the following checks: + * - File exists and is readable + * - Respects project boundary settings (allowAccessOutsideProject) + * - Logs access to files outside project scope + * + * @param filePath Absolute path to file + * @return File content as QString, or null QString on error + */ + static QString readFileContent(const QString &filePath); + + /** + * @brief Search for files in filesystem directory tree + * + * Recursively searches a directory for files matching the query. + * Respects .qodeassistignore patterns and depth limits. + * + * @param dirPath Directory to search in + * @param query Search query (case-insensitive) + * @param projectName Name of the project for metadata + * @param projectDir Root directory of the project + * @param project Project instance for ignore checking + * @param matches Output list to append matches to + * @param maxResults Stop after finding this many matches + * @param currentDepth Current recursion depth (modified during recursion) + * @param maxDepth Maximum recursion depth + * @param ignoreManager IgnoreManager instance for filtering files + */ + static void searchInFileSystem( + const QString &dirPath, + const QString &query, + const QString &projectName, + const QString &projectDir, + ProjectExplorer::Project *project, + QList &matches, + int maxResults, + int ¤tDepth, + int maxDepth = 5, + Context::IgnoreManager *ignoreManager = nullptr); +}; + +} // namespace QodeAssist::Tools diff --git a/tools/FindAndReadFileTool.cpp b/tools/FindAndReadFileTool.cpp index a54b324..6ef9594 100644 --- a/tools/FindAndReadFileTool.cpp +++ b/tools/FindAndReadFileTool.cpp @@ -20,17 +20,9 @@ #include "FindAndReadFileTool.hpp" #include "ToolExceptions.hpp" -#include #include -#include -#include -#include -#include -#include -#include #include #include -#include #include namespace QodeAssist::Tools { @@ -109,14 +101,15 @@ QFuture FindAndReadFileTool::executeAsync(const QJsonObject &input) .arg(query, filePattern.isEmpty() ? "none" : filePattern) .arg(readContent)); - FileMatch bestMatch = findBestMatch(query, filePattern, 10); + FileSearchUtils::FileMatch bestMatch = FileSearchUtils::findBestMatch( + query, filePattern, 10, m_ignoreManager); if (bestMatch.absolutePath.isEmpty()) { return QString("No file found matching '%1'").arg(query); } if (readContent) { - bestMatch.content = readFileContent(bestMatch.absolutePath); + bestMatch.content = FileSearchUtils::readFileContent(bestMatch.absolutePath); if (bestMatch.content.isNull()) { bestMatch.error = "Could not read file"; } @@ -126,221 +119,8 @@ QFuture FindAndReadFileTool::executeAsync(const QJsonObject &input) }); } -FindAndReadFileTool::FileMatch FindAndReadFileTool::findBestMatch( - const QString &query, const QString &filePattern, int maxResults) -{ - QList candidates; - auto projects = ProjectExplorer::ProjectManager::projects(); - - if (projects.isEmpty()) { - return FileMatch{}; - } - - QFileInfo queryInfo(query); - if (queryInfo.isAbsolute() && queryInfo.exists() && queryInfo.isFile()) { - FileMatch match; - match.absolutePath = queryInfo.canonicalFilePath(); - - for (auto project : projects) { - if (!project) - continue; - QString projectDir = project->projectDirectory().path(); - if (match.absolutePath.startsWith(projectDir)) { - match.relativePath = QDir(projectDir).relativeFilePath(match.absolutePath); - match.projectName = project->displayName(); - match.matchType = MatchType::ExactName; - return match; - } - } - - match.relativePath = queryInfo.fileName(); - match.projectName = "External"; - match.matchType = MatchType::ExactName; - return match; - } - - QString lowerQuery = query.toLower(); - - for (auto project : projects) { - if (!project) - continue; - - auto projectFiles = project->files(ProjectExplorer::Project::SourceFiles); - QString projectDir = project->projectDirectory().path(); - QString projectName = project->displayName(); - - for (const auto &filePath : projectFiles) { - QString absolutePath = filePath.path(); - if (m_ignoreManager->shouldIgnore(absolutePath, project)) - continue; - - QFileInfo fileInfo(absolutePath); - QString fileName = fileInfo.fileName(); - - if (!filePattern.isEmpty() && !matchesFilePattern(fileName, filePattern)) - continue; - - QString relativePath = QDir(projectDir).relativeFilePath(absolutePath); - - FileMatch match; - match.absolutePath = absolutePath; - match.relativePath = relativePath; - match.projectName = projectName; - - QString lowerFileName = fileName.toLower(); - QString lowerRelativePath = relativePath.toLower(); - - if (lowerFileName == lowerQuery) { - match.matchType = MatchType::ExactName; - candidates.append(match); - } else if (lowerRelativePath.contains(lowerQuery)) { - match.matchType = MatchType::PathMatch; - candidates.append(match); - } else if (lowerFileName.contains(lowerQuery)) { - match.matchType = MatchType::PartialName; - candidates.append(match); - } - } - } - - if (candidates.isEmpty() || candidates.first().matchType != MatchType::ExactName) { - for (auto project : projects) { - if (!project) - continue; - - QString projectDir = project->projectDirectory().path(); - QString projectName = project->displayName(); - int depth = 0; - searchInFileSystem( - projectDir, - lowerQuery, - projectName, - projectDir, - project, - candidates, - maxResults, - depth); - } - } - - if (candidates.isEmpty()) { - return FileMatch{}; - } - - std::sort(candidates.begin(), candidates.end()); - return candidates.first(); -} - -void FindAndReadFileTool::searchInFileSystem( - const QString &dirPath, - const QString &query, - const QString &projectName, - const QString &projectDir, - ProjectExplorer::Project *project, - QList &matches, - int maxResults, - int ¤tDepth, - int maxDepth) -{ - if (currentDepth >= maxDepth || matches.size() >= maxResults) - return; - - currentDepth++; - QDir dir(dirPath); - if (!dir.exists()) { - currentDepth--; - return; - } - - auto entries = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); - for (const auto &entry : entries) { - if (matches.size() >= maxResults) - break; - - QString absolutePath = entry.absoluteFilePath(); - if (m_ignoreManager->shouldIgnore(absolutePath, project)) - continue; - - QString fileName = entry.fileName(); - - if (entry.isDir()) { - searchInFileSystem( - absolutePath, - query, - projectName, - projectDir, - project, - matches, - maxResults, - currentDepth, - maxDepth); - continue; - } - - QString lowerFileName = fileName.toLower(); - QString relativePath = QDir(projectDir).relativeFilePath(absolutePath); - QString lowerRelativePath = relativePath.toLower(); - - FileMatch match; - match.absolutePath = absolutePath; - match.relativePath = relativePath; - match.projectName = projectName; - - if (lowerFileName == query) { - match.matchType = MatchType::ExactName; - matches.append(match); - } else if (lowerRelativePath.contains(query)) { - match.matchType = MatchType::PathMatch; - matches.append(match); - } else if (lowerFileName.contains(query)) { - match.matchType = MatchType::PartialName; - matches.append(match); - } - } - - currentDepth--; -} - -bool FindAndReadFileTool::matchesFilePattern(const QString &fileName, const QString &pattern) const -{ - if (pattern.isEmpty()) - return true; - - if (pattern.startsWith("*.")) { - QString extension = pattern.mid(1); - return fileName.endsWith(extension, Qt::CaseInsensitive); - } - - return fileName.compare(pattern, Qt::CaseInsensitive) == 0; -} - -QString FindAndReadFileTool::readFileContent(const QString &filePath) const -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QString(); - } - - QString canonicalPath = QFileInfo(filePath).canonicalFilePath(); - bool isInProject = Context::ProjectUtils::isFileInProject(canonicalPath); - - if (!isInProject) { - const auto &settings = Settings::generalSettings(); - if (!settings.allowAccessOutsideProject()) { - LOG_MESSAGE(QString("Access denied to file outside project: %1").arg(canonicalPath)); - return QString(); - } - LOG_MESSAGE(QString("Reading file outside project scope: %1").arg(canonicalPath)); - } - - QTextStream stream(&file); - stream.setAutoDetectUnicode(true); - QString content = stream.readAll(); - - return content; -} - -QString FindAndReadFileTool::formatResult(const FileMatch &match, bool readContent) const +QString FindAndReadFileTool::formatResult(const FileSearchUtils::FileMatch &match, + bool readContent) const { QString result = QString("Found file: %1\nAbsolute path: %2").arg(match.relativePath, match.absolutePath); diff --git a/tools/FindAndReadFileTool.hpp b/tools/FindAndReadFileTool.hpp index 6b98bdf..7311529 100644 --- a/tools/FindAndReadFileTool.hpp +++ b/tools/FindAndReadFileTool.hpp @@ -19,6 +19,8 @@ #pragma once +#include "FileSearchUtils.hpp" + #include #include #include @@ -42,38 +44,7 @@ public: QFuture executeAsync(const QJsonObject &input) override; private: - enum class MatchType { ExactName, PathMatch, PartialName }; - - struct FileMatch - { - QString absolutePath; - QString relativePath; - QString projectName; - QString content; - MatchType matchType; - bool contentRead = false; - QString error; - - bool operator<(const FileMatch &other) const - { - return static_cast(matchType) < static_cast(other.matchType); - } - }; - - FileMatch findBestMatch(const QString &query, const QString &filePattern, int maxResults); - void searchInFileSystem( - const QString &dirPath, - const QString &query, - const QString &projectName, - const QString &projectDir, - ProjectExplorer::Project *project, - QList &matches, - int maxResults, - int ¤tDepth, - int maxDepth = 5); - bool matchesFilePattern(const QString &fileName, const QString &pattern) const; - QString readFileContent(const QString &filePath) const; - QString formatResult(const FileMatch &match, bool readContent) const; + QString formatResult(const FileSearchUtils::FileMatch &match, bool readContent) const; Context::IgnoreManager *m_ignoreManager; }; diff --git a/tools/ToolsFactory.cpp b/tools/ToolsFactory.cpp index 3d9b343..b4608e8 100644 --- a/tools/ToolsFactory.cpp +++ b/tools/ToolsFactory.cpp @@ -21,11 +21,13 @@ #include "logger/Logger.hpp" #include +#include #include #include #include "BuildProjectTool.hpp" #include "CreateNewFileTool.hpp" +#include "EditFileTool.hpp" #include "FindAndReadFileTool.hpp" #include "GetIssuesListTool.hpp" #include "ListProjectFilesTool.hpp" @@ -46,6 +48,7 @@ void ToolsFactory::registerTools() registerTool(new ListProjectFilesTool(this)); registerTool(new GetIssuesListTool(this)); registerTool(new CreateNewFileTool(this)); + registerTool(new EditFileTool(this)); registerTool(new BuildProjectTool(this)); registerTool(new ProjectSearchTool(this)); registerTool(new FindAndReadFileTool(this)); @@ -81,13 +84,17 @@ LLMCore::BaseTool *ToolsFactory::getToolByName(const QString &name) const QJsonArray ToolsFactory::getToolsDefinitions(LLMCore::ToolSchemaFormat format) const { QJsonArray toolsArray; - const auto &settings = Settings::generalSettings(); + const auto &settings = Settings::toolsSettings(); for (auto it = m_tools.constBegin(); it != m_tools.constEnd(); ++it) { if (!it.value()) { continue; } + if (it.value()->name() == "edit_file" && !settings.enableEditFileTool()) { + continue; + } + const auto requiredPerms = it.value()->requiredPermissions(); bool hasPermission = true; diff --git a/tools/ToolsManager.cpp b/tools/ToolsManager.cpp index 2729013..b5c6fc9 100644 --- a/tools/ToolsManager.cpp +++ b/tools/ToolsManager.cpp @@ -59,7 +59,6 @@ void ToolsManager::executeToolCall( auto &queue = m_toolQueues[requestId]; - // Check if tool already exists in queue or completed for (const auto &tool : queue.queue) { if (tool.id == toolId) { LOG_MESSAGE(QString("Tool %1 already in queue for request %2").arg(toolId, requestId)); @@ -73,15 +72,16 @@ void ToolsManager::executeToolCall( return; } - // Add tool to queue - PendingTool pendingTool{toolId, toolName, input, "", false}; + QJsonObject modifiedInput = input; + modifiedInput["_request_id"] = requestId; + + PendingTool pendingTool{toolId, toolName, modifiedInput, "", false}; queue.queue.append(pendingTool); LOG_MESSAGE(QString("ToolsManager: Tool %1 added to queue (position %2)") .arg(toolName) .arg(queue.queue.size())); - // Start execution if not already running if (!queue.isExecuting) { executeNextTool(requestId); } @@ -95,7 +95,6 @@ void ToolsManager::executeNextTool(const QString &requestId) auto &queue = m_toolQueues[requestId]; - // Check if queue is empty if (queue.queue.isEmpty()) { LOG_MESSAGE(QString("ToolsManager: All tools complete for request %1, emitting results") .arg(requestId)); @@ -105,7 +104,6 @@ void ToolsManager::executeNextTool(const QString &requestId) return; } - // Get next tool from queue PendingTool tool = queue.queue.takeFirst(); queue.isExecuting = true; @@ -116,7 +114,6 @@ void ToolsManager::executeNextTool(const QString &requestId) auto toolInstance = m_toolsFactory->getToolByName(tool.name); if (!toolInstance) { LOG_MESSAGE(QString("ToolsManager: Tool not found: %1").arg(tool.name)); - // Mark as failed and continue to next tool tool.result = QString("Error: Tool not found: %1").arg(tool.name); tool.complete = true; queue.completed[tool.id] = tool; @@ -124,7 +121,6 @@ void ToolsManager::executeNextTool(const QString &requestId) return; } - // Store tool in completed map (will be updated when finished) queue.completed[tool.id] = tool; m_toolHandler->executeToolAsync(requestId, tool.id, toolInstance, tool.input); @@ -176,7 +172,6 @@ void ToolsManager::onToolFinished( .arg(success ? QString("completed") : QString("failed")) .arg(requestId)); - // Execute next tool in queue executeNextTool(requestId); }