feat: Add edit file tool (#249)

* feat: Add edit file tool
* feat: Add icons for action buttons
This commit is contained in:
Petr Mironychev
2025-11-03 08:56:52 +01:00
committed by GitHub
parent e7110810f8
commit 9b90aaa06e
39 changed files with 3732 additions and 344 deletions

View File

@ -121,9 +121,11 @@ add_qtc_plugin(QodeAssist
tools/ToolsManager.hpp tools/ToolsManager.cpp tools/ToolsManager.hpp tools/ToolsManager.cpp
tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp
tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp
tools/EditFileTool.hpp tools/EditFileTool.cpp
tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp
tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp
tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.cpp tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.cpp
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
providers/ClaudeMessage.hpp providers/ClaudeMessage.cpp providers/ClaudeMessage.hpp providers/ClaudeMessage.cpp
providers/OpenAIMessage.hpp providers/OpenAIMessage.cpp providers/OpenAIMessage.hpp providers/OpenAIMessage.cpp
providers/OllamaMessage.hpp providers/OllamaMessage.cpp providers/OllamaMessage.hpp providers/OllamaMessage.cpp

View File

@ -16,9 +16,11 @@ qt_add_qml_module(QodeAssistChatView
qml/parts/TopBar.qml qml/parts/TopBar.qml
qml/parts/BottomBar.qml qml/parts/BottomBar.qml
qml/parts/AttachedFilesPlace.qml qml/parts/AttachedFilesPlace.qml
qml/parts/ErrorToast.qml qml/parts/Toast.qml
qml/ToolStatusItem.qml qml/ToolStatusItem.qml
qml/FileEditItem.qml
qml/parts/RulesViewer.qml qml/parts/RulesViewer.qml
qml/parts/FileEditsActionBar.qml
RESOURCES RESOURCES
icons/attach-file-light.svg icons/attach-file-light.svg
@ -36,6 +38,10 @@ qt_add_qml_module(QodeAssistChatView
icons/chat-icon.svg icons/chat-icon.svg
icons/chat-pause-icon.svg icons/chat-pause-icon.svg
icons/rules-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 SOURCES
ChatWidget.hpp ChatWidget.cpp ChatWidget.hpp ChatWidget.cpp

View File

@ -26,6 +26,7 @@
#include "ChatAssistantSettings.hpp" #include "ChatAssistantSettings.hpp"
#include "Logger.hpp" #include "Logger.hpp"
#include "context/ChangesManager.h"
namespace QodeAssist::Chat { namespace QodeAssist::Chat {
@ -39,6 +40,21 @@ ChatModel::ChatModel(QObject *parent)
&Utils::BaseAspect::changed, &Utils::BaseAspect::changed,
this, this,
&ChatModel::tokensThresholdChanged); &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 int ChatModel::rowCount(const QModelIndex &parent) const
@ -106,6 +122,45 @@ void ChatModel::addMessage(
newMessage.attachments = attachments; newMessage.attachments = attachments;
m_messages.append(newMessage); m_messages.append(newMessage);
endInsertRows(); 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; break;
case ChatRole::Tool: case ChatRole::Tool:
case ChatRole::FileEdit: case ChatRole::FileEdit:
// Skip Tool and FileEdit messages - they are UI-only
continue; continue;
default: default:
continue; continue;
@ -326,8 +380,11 @@ void ChatModel::updateToolResult(
} else { } else {
QJsonObject editData = doc.object(); QJsonObject editData = doc.object();
// Generate unique edit ID based on timestamp QString editId = editData.value("edit_id").toString();
QString editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch());
if (editId.isEmpty()) {
editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch());
}
LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId)); 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 } // namespace QodeAssist::Chat

View File

@ -83,12 +83,25 @@ public:
const QString &toolId, const QString &toolId,
const QString &toolName, const QString &toolName,
const QString &result); const QString &result);
void updateMessageContent(const QString &messageId, const QString &newContent);
void setLoadingFromHistory(bool loading);
bool isLoadingFromHistory() const;
signals: signals:
void tokensThresholdChanged(); void tokensThresholdChanged();
void modelReseted(); void modelReseted();
private slots:
void onFileEditApplied(const QString &editId);
void onFileEditRejected(const QString &editId);
void onFileEditArchived(const QString &editId);
private: private:
void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage);
QVector<Message> m_messages; QVector<Message> m_messages;
bool m_loadingFromHistory = false;
}; };
} // namespace QodeAssist::Chat } // namespace QodeAssist::Chat

View File

@ -29,14 +29,17 @@
#include <projectexplorer/project.h> #include <projectexplorer/project.h>
#include <projectexplorer/projectexplorer.h> #include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectmanager.h> #include <projectexplorer/projectmanager.h>
#include <texteditor/texteditor.h>
#include <utils/theme/theme.h> #include <utils/theme/theme.h>
#include <utils/utilsicons.h> #include <utils/utilsicons.h>
#include "ChatAssistantSettings.hpp" #include "ChatAssistantSettings.hpp"
#include "ChatSerializer.hpp" #include "ChatSerializer.hpp"
#include "GeneralSettings.hpp" #include "GeneralSettings.hpp"
#include "ToolsSettings.hpp"
#include "Logger.hpp" #include "Logger.hpp"
#include "ProjectSettings.hpp" #include "ProjectSettings.hpp"
#include "context/ChangesManager.h"
#include "context/ContextManager.hpp" #include "context/ContextManager.hpp"
#include "context/TokenUtils.hpp" #include "context/TokenUtils.hpp"
#include "llmcore/RulesLoader.hpp" #include "llmcore/RulesLoader.hpp"
@ -78,7 +81,11 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this, this,
&ChatRootView::updateInputTokensCount); &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::attachmentFilesChanged, &ChatRootView::updateInputTokensCount);
connect(this, &ChatRootView::linkedFilesChanged, &ChatRootView::updateInputTokensCount); connect(this, &ChatRootView::linkedFilesChanged, &ChatRootView::updateInputTokensCount);
connect( connect(
@ -138,6 +145,46 @@ ChatRootView::ChatRootView(QQuickItem *parent)
m_lastErrorMessage = error; m_lastErrorMessage = error;
emit lastErrorMessageChanged(); 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(); updateInputTokensCount();
refreshRules(); refreshRules();
@ -152,7 +199,7 @@ ChatRootView::ChatRootView(QQuickItem *parent)
m_isAgentMode = appSettings.value("QodeAssist/Chat/AgentMode", false).toBool(); m_isAgentMode = appSettings.value("QodeAssist/Chat/AgentMode", false).toBool();
connect( connect(
&Settings::generalSettings().useTools, &Settings::toolsSettings().useTools,
&Utils::BaseAspect::changed, &Utils::BaseAspect::changed,
this, this,
&ChatRootView::toolsSupportEnabledChanged); &ChatRootView::toolsSupportEnabledChanged);
@ -258,7 +305,10 @@ void ChatRootView::loadHistory(const QString &filePath)
} else { } else {
setRecentFilePath(filePath); setRecentFilePath(filePath);
} }
m_currentMessageRequestId.clear();
updateInputTokensCount(); updateInputTokensCount();
updateCurrentMessageEditsStats();
} }
void ChatRootView::showSaveDialog() void ChatRootView::showSaveDialog()
@ -731,7 +781,310 @@ void ChatRootView::setIsAgentMode(bool newIsAgentMode)
bool ChatRootView::toolsSupportEnabled() const 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<TextEditor::BaseTextEditor *>(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 } // namespace QodeAssist::Chat

View File

@ -45,11 +45,17 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(int textFormat READ textFormat NOTIFY textFormatChanged FINAL) Q_PROPERTY(int textFormat READ textFormat NOTIFY textFormatChanged FINAL)
Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL) Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL)
Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL) Q_PROPERTY(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(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL)
Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
Q_PROPERTY(bool isAgentMode READ isAgentMode WRITE setIsAgentMode NOTIFY isAgentModeChanged FINAL) Q_PROPERTY(bool isAgentMode READ isAgentMode WRITE setIsAgentMode NOTIFY isAgentModeChanged FINAL)
Q_PROPERTY( Q_PROPERTY(
bool toolsSupportEnabled READ toolsSupportEnabled NOTIFY toolsSupportEnabledChanged FINAL) 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 QML_ELEMENT
@ -114,6 +120,23 @@ public:
void setIsAgentMode(bool newIsAgentMode); void setIsAgentMode(bool newIsAgentMode);
bool toolsSupportEnabled() const; 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: public slots:
void sendMessage(const QString &message); void sendMessage(const QString &message);
void copyToClipboard(const QString &text); void copyToClipboard(const QString &text);
@ -138,13 +161,16 @@ signals:
void isRequestInProgressChanged(); void isRequestInProgressChanged();
void lastErrorMessageChanged(); void lastErrorMessageChanged();
void lastInfoMessageChanged();
void activeRulesChanged(); void activeRulesChanged();
void activeRulesCountChanged(); void activeRulesCountChanged();
void isAgentModeChanged(); void isAgentModeChanged();
void toolsSupportEnabledChanged(); void toolsSupportEnabledChanged();
void currentMessageEditsStatsChanged();
private: private:
void updateFileEditStatus(const QString &editId, const QString &status);
QString getChatsHistoryDir() const; QString getChatsHistoryDir() const;
QString getSuggestedFileName() const; QString getSuggestedFileName() const;
@ -163,6 +189,13 @@ private:
QString m_lastErrorMessage; QString m_lastErrorMessage;
QVariantList m_activeRules; QVariantList m_activeRules;
bool m_isAgentMode; 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 } // namespace QodeAssist::Chat

View File

@ -120,9 +120,14 @@ bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json)
} }
model->clear(); model->clear();
model->setLoadingFromHistory(true);
for (const auto &message : messages) { for (const auto &message : messages) {
model->addMessage(message.content, message.role, message.id); model->addMessage(message.content, message.role, message.id);
} }
model->setLoadingFromHistory(false);
return true; return true;
} }

View File

@ -37,9 +37,11 @@
#include "ChatAssistantSettings.hpp" #include "ChatAssistantSettings.hpp"
#include "GeneralSettings.hpp" #include "GeneralSettings.hpp"
#include "ToolsSettings.hpp"
#include "Logger.hpp" #include "Logger.hpp"
#include "ProvidersManager.hpp" #include "ProvidersManager.hpp"
#include "RequestConfig.hpp" #include "RequestConfig.hpp"
#include <context/ChangesManager.h>
#include <RulesLoader.hpp> #include <RulesLoader.hpp>
namespace QodeAssist::Chat { namespace QodeAssist::Chat {
@ -65,6 +67,8 @@ void ClientInterface::sendMessage(
{ {
cancelRequest(); cancelRequest();
m_accumulatedResponses.clear(); m_accumulatedResponses.clear();
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
auto attachFiles = m_contextManager->getContentFiles(attachments); auto attachFiles = m_contextManager->getContentFiles(attachments);
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles); m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles);
@ -89,7 +93,7 @@ void ClientInterface::sendMessage(
LLMCore::ContextData context; LLMCore::ContextData context;
const bool isToolsEnabled = Settings::generalSettings().useTools() && useAgentMode; const bool isToolsEnabled = Settings::toolsSettings().useTools() && useAgentMode;
if (chatAssistantSettings.useSystemPrompt()) { if (chatAssistantSettings.useSystemPrompt()) {
QString systemPrompt = chatAssistantSettings.systemPrompt(); QString systemPrompt = chatAssistantSettings.systemPrompt();
@ -155,6 +159,8 @@ void ClientInterface::sendMessage(
QJsonObject request{{"id", requestId}}; QJsonObject request{{"id", requestId}};
m_activeRequests[requestId] = {request, provider}; m_activeRequests[requestId] = {request, provider};
emit requestStarted(requestId);
connect( connect(
provider, provider,
@ -312,6 +318,16 @@ void ClientInterface::handleFullResponse(const QString &requestId, const QString
const RequestContext &ctx = it.value(); const RequestContext &ctx = it.value();
QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId]; 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); handleLLMResponse(finalText, ctx.originalRequest, true);
m_activeRequests.erase(it); m_activeRequests.erase(it);

View File

@ -52,6 +52,7 @@ public:
signals: signals:
void errorOccurred(const QString &error); void errorOccurred(const QString &error);
void messageReceivedCompletely(); void messageReceivedCompletely();
void requestStarted(const QString &requestId);
private slots: private slots:
void handlePartialResponse(const QString &requestId, const QString &partialText); void handlePartialResponse(const QString &requestId, const QString &partialText);

View File

@ -0,0 +1,15 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_74_61)">
<mask id="mask0_74_61" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
<path d="M44 0H0V44H44V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_74_61)">
<path d="M8 22L18 32L36 12" stroke="black" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_61">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1,17 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_74_52)">
<mask id="mask0_74_52" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
<path d="M44 0H0V44H44V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_74_52)">
<path d="M18 31C25.1797 31 31 25.1797 31 18C31 10.8203 25.1797 5 18 5C10.8203 5 5 10.8203 5 18C5 25.1797 10.8203 31 18 31Z" stroke="black" stroke-width="3.5"/>
<path d="M27 27L38 38" stroke="black" stroke-width="3.5" stroke-linecap="round"/>
<path d="M16.375 23L18.2841 11.3636H20.1023L18.1932 23H16.375ZM11.1648 20.1136L11.4659 18.2955H20.5568L20.2557 20.1136H11.1648ZM12.2841 23L14.1932 11.3636H16.0114L14.1023 23H12.2841ZM11.8295 16.0682L12.1364 14.25H21.2273L20.9205 16.0682H11.8295Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_52">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 943 B

View File

@ -0,0 +1,16 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_74_76)">
<mask id="mask0_74_76" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
<path d="M44 0H0V44H44V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_74_76)">
<path d="M12 12L32 32" stroke="black" stroke-width="4" stroke-linecap="round"/>
<path d="M32 12L12 32" stroke="black" stroke-width="4" stroke-linecap="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_76">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@ -0,0 +1,16 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_74_68)">
<mask id="mask0_74_68" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
<path d="M44 0H0V44H44V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_74_68)">
<path d="M12 12L6 18L12 24" stroke="black" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 18H28C33 18 38 23 38 28C38 33 33 38 28 38H22" stroke="black" stroke-width="4" stroke-linecap="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_68">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}
}

View File

@ -115,7 +115,7 @@ ChatRootView {
if (model.roleType === ChatModel.Tool) { if (model.roleType === ChatModel.Tool) {
return toolMessageComponent return toolMessageComponent
} else if (model.roleType === ChatModel.FileEdit) { } else if (model.roleType === ChatModel.FileEdit) {
return toolMessageComponent return fileEditMessageComponent
} else { } else {
return chatItemComponent return chatItemComponent
} }
@ -174,6 +174,31 @@ ChatRootView {
toolContent: model.content 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 { ScrollView {
@ -280,6 +305,19 @@ ChatRootView {
onRemoveFileFromListByIndex: (index) => root.removeFileFromLinkList(index) 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 { BottomBar {
id: bottomBar id: bottomBar
@ -329,9 +367,22 @@ ChatRootView {
scrollToBottom() scrollToBottom()
} }
ErrorToast { Toast {
id: errorToast id: errorToast
z: 1000 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 { RulesViewer {
@ -356,6 +407,11 @@ ChatRootView {
errorToast.show(root.lastErrorMessage) errorToast.show(root.lastErrorMessage)
} }
} }
function onLastInfoMessageChanged() {
if (root.lastInfoMessage.length > 0) {
infoToast.show(root.lastInfoMessage)
}
}
} }
Component.onCompleted: { Component.onCompleted: {

View File

@ -87,6 +87,7 @@ Rectangle {
TextEdit { TextEdit {
id: resultText id: resultText
width: parent.width
text: root.toolResult text: root.toolResult
readOnly: true readOnly: true
selectByMouse: true selectByMouse: true

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}

View File

@ -20,13 +20,16 @@
import QtQuick import QtQuick
Rectangle { Rectangle {
id: errorToast id: root
property alias toastTextItem: textItem
property alias toastTextColor: textItem.color
property string errorText: "" property string errorText: ""
property int displayDuration: 5000 property int displayDuration: 5000
width: Math.min(parent.width - 40, errorTextItem.implicitWidth + radius) width: Math.min(parent.width - 40, textItem.implicitWidth + radius)
height: visible ? (errorTextItem.implicitHeight + 12) : 0 height: visible ? (textItem.implicitHeight + 12) : 0
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 10 anchors.topMargin: 10
@ -39,15 +42,15 @@ Rectangle {
opacity: 0 opacity: 0
TextEdit { TextEdit {
id: errorTextItem id: textItem
anchors.centerIn: parent anchors.centerIn: parent
anchors.margins: 6 anchors.margins: 6
text: errorToast.errorText text: root.errorText
color: "#ffffff" color: palette.text
font.pixelSize: 13 font.pixelSize: 13
wrapMode: TextEdit.Wrap wrapMode: TextEdit.Wrap
width: Math.min(implicitWidth, errorToast.parent.width - 60) width: Math.min(implicitWidth, root.parent.width - 60)
horizontalAlignment: TextEdit.AlignHCenter horizontalAlignment: TextEdit.AlignHCenter
readOnly: true readOnly: true
selectByMouse: true selectByMouse: true
@ -69,7 +72,7 @@ Rectangle {
NumberAnimation { NumberAnimation {
id: showAnimation id: showAnimation
target: errorToast target: root
property: "opacity" property: "opacity"
from: 0 from: 0
to: 1 to: 1
@ -80,21 +83,21 @@ Rectangle {
NumberAnimation { NumberAnimation {
id: hideAnimation id: hideAnimation
target: errorToast target: root
property: "opacity" property: "opacity"
from: 1 from: 1
to: 0 to: 0
duration: 200 duration: 200
easing.type: Easing.InQuad easing.type: Easing.InQuad
onFinished: errorToast.visible = false onFinished: root.visible = false
} }
Timer { Timer {
id: hideTimer id: hideTimer
interval: errorToast.displayDuration interval: root.displayDuration
running: false running: false
repeat: false repeat: false
onTriggered: errorToast.hide() onTriggered: root.hide()
} }
} }

View File

@ -54,9 +54,10 @@ Item {
Rectangle { Rectangle {
id: slider 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 width: parent.width / 2
height: parent.height height: parent.height - 2
opacity: 0.6 opacity: 0.6
radius: height / 2 radius: height / 2

File diff suppressed because it is too large Load Diff

View File

@ -22,8 +22,10 @@
#include <texteditor/textdocument.h> #include <texteditor/textdocument.h>
#include <QDateTime> #include <QDateTime>
#include <QHash> #include <QHash>
#include <QMutex>
#include <QQueue> #include <QQueue>
#include <QTimer> #include <QTimer>
#include <QUndoStack>
namespace QodeAssist::Context { namespace QodeAssist::Context {
@ -39,21 +41,117 @@ public:
QString lineContent; 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<DiffHunk> 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(); static ChangesManager &instance();
void addChange( void addChange(
TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded); TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded);
QString getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const; 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<FileEdit> getPendingEdits() const;
bool applyPendingEditsForRequest(const QString &requestId, QString *errorMsg = nullptr);
QList<FileEdit> 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: private:
ChangesManager(); ChangesManager();
~ChangesManager(); ~ChangesManager();
ChangesManager(const ChangesManager &) = delete; ChangesManager(const ChangesManager &) = delete;
ChangesManager &operator=(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<TextEditor::TextDocument *, QQueue<ChangeInfo>> m_documentChanges; QHash<TextEditor::TextDocument *, QQueue<ChangeInfo>> m_documentChanges;
QHash<QString, FileEdit> m_fileEdits;
QHash<QString, RequestEdits> m_requestEdits; // requestId → ordered edits
QUndoStack *m_undoStack;
mutable QMutex m_mutex;
}; };
} // namespace QodeAssist::Context } // namespace QodeAssist::Context

View File

@ -51,4 +51,23 @@ bool ProjectUtils::isFileInProject(const QString &filePath)
return false; return false;
} }
QString ProjectUtils::findFileInProject(const QString &filename)
{
QList<ProjectExplorer::Project *> 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 } // namespace QodeAssist::Context

View File

@ -41,6 +41,17 @@ public:
* @return true if file is part of any open project, false otherwise * @return true if file is part of any open project, false otherwise
*/ */
static bool isFileInProject(const QString &filePath); 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 } // namespace QodeAssist::Context

View File

@ -7,6 +7,7 @@ add_library(QodeAssistSettings STATIC
SettingsTr.hpp SettingsTr.hpp
CodeCompletionSettings.hpp CodeCompletionSettings.cpp CodeCompletionSettings.hpp CodeCompletionSettings.cpp
ChatAssistantSettings.hpp ChatAssistantSettings.cpp ChatAssistantSettings.hpp ChatAssistantSettings.cpp
ToolsSettings.hpp ToolsSettings.cpp
SettingsDialog.hpp SettingsDialog.cpp SettingsDialog.hpp SettingsDialog.cpp
ProjectSettings.hpp ProjectSettings.cpp ProjectSettings.hpp ProjectSettings.cpp
ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp

View File

@ -205,40 +205,6 @@ GeneralSettings::GeneralSettings()
caTemplateDescription.setDefaultValue(""); caTemplateDescription.setDefaultValue("");
caTemplateDescription.setLabelText(TrConstants::CURRENT_TEMPLATE_DESCRIPTION); 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(); readSettings();
Logger::instance().setLoggingEnabled(enableLogging()); Logger::instance().setLoggingEnabled(enableLogging());
@ -286,9 +252,6 @@ GeneralSettings::GeneralSettings()
title(TrConstants::CHAT_ASSISTANT), title(TrConstants::CHAT_ASSISTANT),
Column{ Column{
caGrid, caGrid,
Column{
useTools, allowFileSystemRead, allowFileSystemWrite, allowAccessOutsideProject,
/*autoApplyFileEdits*/},
caTemplateDescription}}; caTemplateDescription}};
auto rootLayout = Column{ auto rootLayout = Column{
@ -543,11 +506,6 @@ void GeneralSettings::resetPageToDefaults()
resetAspect(ccPreset1CustomEndpoint); resetAspect(ccPreset1CustomEndpoint);
resetAspect(caEndpointMode); resetAspect(caEndpointMode);
resetAspect(caCustomEndpoint); resetAspect(caCustomEndpoint);
resetAspect(useTools);
resetAspect(allowFileSystemRead);
resetAspect(allowFileSystemWrite);
resetAspect(allowAccessOutsideProject);
resetAspect(autoApplyFileEdits);
writeSettings(); writeSettings();
} }
} }

View File

@ -100,12 +100,6 @@ public:
Utils::StringAspect caStatus{this}; Utils::StringAspect caStatus{this};
ButtonAspect caTest{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}; Utils::StringAspect caTemplateDescription{this};
void showSelectionDialog( void showSelectionDialog(

View File

@ -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_READ[] = "QodeAssist.caAllowFileSystemRead";
const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite"; const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite";
const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject"; 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_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; 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"; = "QodeAssist.2CodeCompletionSettingsPageId";
const char QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID[] const char QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID[]
= "QodeAssist.3ChatAssistantSettingsPageId"; = "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_CATEGORY[] = "QodeAssist.Category";
const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist"; const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
// Provider Settings Page ID // 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 // Provider API Keys
const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey"; const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey";

157
settings/ToolsSettings.cpp Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#include "ToolsSettings.hpp"
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/icore.h>
#include <utils/layoutbuilder.h>
#include <QMessageBox>
#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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <utils/aspects.h>
#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

View File

@ -49,7 +49,7 @@ QString BuildProjectTool::stringName() const
QString BuildProjectTool::description() const QString BuildProjectTool::description() const
{ {
return "Build the current project in Qt Creator. " 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)."; "Optional 'rebuild' parameter: set to true to force a clean rebuild (default: false).";
} }

View File

@ -23,6 +23,7 @@
#include <context/ProjectUtils.hpp> #include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp> #include <logger/Logger.hpp>
#include <settings/GeneralSettings.hpp> #include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
@ -103,7 +104,7 @@ QFuture<QString> CreateNewFileTool::executeAsync(const QJsonObject &input)
bool isInProject = Context::ProjectUtils::isFileInProject(absolutePath); bool isInProject = Context::ProjectUtils::isFileInProject(absolutePath);
if (!isInProject) { if (!isInProject) {
const auto &settings = Settings::generalSettings(); const auto &settings = Settings::toolsSettings();
if (!settings.allowAccessOutsideProject()) { if (!settings.allowAccessOutsideProject()) {
throw ToolRuntimeError( throw ToolRuntimeError(
QString("Error: File path '%1' is not within the current project. " QString("Error: File path '%1' is not within the current project. "

215
tools/EditFileTool.cpp Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#include "EditFileTool.hpp"
#include "ToolExceptions.hpp"
#include <context/ChangesManager.h>
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp>
#include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QUuid>
#include <QtConcurrent>
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<QString> 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

48
tools/EditFileTool.hpp Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include "FileSearchUtils.hpp"
#include <context/IgnoreManager.hpp>
#include <llmcore/BaseTool.hpp>
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<QString> executeAsync(const QJsonObject &input = QJsonObject()) override;
private:
Context::IgnoreManager *m_ignoreManager;
};
} // namespace QodeAssist::Tools

258
tools/FileSearchUtils.cpp Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#include "FileSearchUtils.hpp"
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QTextStream>
namespace QodeAssist::Tools {
FileSearchUtils::FileMatch FileSearchUtils::findBestMatch(
const QString &query,
const QString &filePattern,
int maxResults,
Context::IgnoreManager *ignoreManager)
{
QList<FileMatch> 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<FileMatch> &matches,
int maxResults,
int &currentDepth,
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

152
tools/FileSearchUtils.hpp Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <context/IgnoreManager.hpp>
#include <QString>
#include <QList>
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<int>(matchType) < static_cast<int>(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<FileMatch> &matches,
int maxResults,
int &currentDepth,
int maxDepth = 5,
Context::IgnoreManager *ignoreManager = nullptr);
};
} // namespace QodeAssist::Tools

View File

@ -20,17 +20,9 @@
#include "FindAndReadFileTool.hpp" #include "FindAndReadFileTool.hpp"
#include "ToolExceptions.hpp" #include "ToolExceptions.hpp"
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp> #include <logger/Logger.hpp>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <settings/GeneralSettings.hpp>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QTextStream>
#include <QtConcurrent> #include <QtConcurrent>
namespace QodeAssist::Tools { namespace QodeAssist::Tools {
@ -109,14 +101,15 @@ QFuture<QString> FindAndReadFileTool::executeAsync(const QJsonObject &input)
.arg(query, filePattern.isEmpty() ? "none" : filePattern) .arg(query, filePattern.isEmpty() ? "none" : filePattern)
.arg(readContent)); .arg(readContent));
FileMatch bestMatch = findBestMatch(query, filePattern, 10); FileSearchUtils::FileMatch bestMatch = FileSearchUtils::findBestMatch(
query, filePattern, 10, m_ignoreManager);
if (bestMatch.absolutePath.isEmpty()) { if (bestMatch.absolutePath.isEmpty()) {
return QString("No file found matching '%1'").arg(query); return QString("No file found matching '%1'").arg(query);
} }
if (readContent) { if (readContent) {
bestMatch.content = readFileContent(bestMatch.absolutePath); bestMatch.content = FileSearchUtils::readFileContent(bestMatch.absolutePath);
if (bestMatch.content.isNull()) { if (bestMatch.content.isNull()) {
bestMatch.error = "Could not read file"; bestMatch.error = "Could not read file";
} }
@ -126,221 +119,8 @@ QFuture<QString> FindAndReadFileTool::executeAsync(const QJsonObject &input)
}); });
} }
FindAndReadFileTool::FileMatch FindAndReadFileTool::findBestMatch( QString FindAndReadFileTool::formatResult(const FileSearchUtils::FileMatch &match,
const QString &query, const QString &filePattern, int maxResults) bool readContent) const
{
QList<FileMatch> 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<FileMatch> &matches,
int maxResults,
int &currentDepth,
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 result QString result
= QString("Found file: %1\nAbsolute path: %2").arg(match.relativePath, match.absolutePath); = QString("Found file: %1\nAbsolute path: %2").arg(match.relativePath, match.absolutePath);

View File

@ -19,6 +19,8 @@
#pragma once #pragma once
#include "FileSearchUtils.hpp"
#include <context/IgnoreManager.hpp> #include <context/IgnoreManager.hpp>
#include <llmcore/BaseTool.hpp> #include <llmcore/BaseTool.hpp>
#include <QFuture> #include <QFuture>
@ -42,38 +44,7 @@ public:
QFuture<QString> executeAsync(const QJsonObject &input) override; QFuture<QString> executeAsync(const QJsonObject &input) override;
private: private:
enum class MatchType { ExactName, PathMatch, PartialName }; QString formatResult(const FileSearchUtils::FileMatch &match, bool readContent) const;
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<int>(matchType) < static_cast<int>(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<FileMatch> &matches,
int maxResults,
int &currentDepth,
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;
Context::IgnoreManager *m_ignoreManager; Context::IgnoreManager *m_ignoreManager;
}; };

View File

@ -21,11 +21,13 @@
#include "logger/Logger.hpp" #include "logger/Logger.hpp"
#include <settings/GeneralSettings.hpp> #include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include "BuildProjectTool.hpp" #include "BuildProjectTool.hpp"
#include "CreateNewFileTool.hpp" #include "CreateNewFileTool.hpp"
#include "EditFileTool.hpp"
#include "FindAndReadFileTool.hpp" #include "FindAndReadFileTool.hpp"
#include "GetIssuesListTool.hpp" #include "GetIssuesListTool.hpp"
#include "ListProjectFilesTool.hpp" #include "ListProjectFilesTool.hpp"
@ -46,6 +48,7 @@ void ToolsFactory::registerTools()
registerTool(new ListProjectFilesTool(this)); registerTool(new ListProjectFilesTool(this));
registerTool(new GetIssuesListTool(this)); registerTool(new GetIssuesListTool(this));
registerTool(new CreateNewFileTool(this)); registerTool(new CreateNewFileTool(this));
registerTool(new EditFileTool(this));
registerTool(new BuildProjectTool(this)); registerTool(new BuildProjectTool(this));
registerTool(new ProjectSearchTool(this)); registerTool(new ProjectSearchTool(this));
registerTool(new FindAndReadFileTool(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 ToolsFactory::getToolsDefinitions(LLMCore::ToolSchemaFormat format) const
{ {
QJsonArray toolsArray; QJsonArray toolsArray;
const auto &settings = Settings::generalSettings(); const auto &settings = Settings::toolsSettings();
for (auto it = m_tools.constBegin(); it != m_tools.constEnd(); ++it) { for (auto it = m_tools.constBegin(); it != m_tools.constEnd(); ++it) {
if (!it.value()) { if (!it.value()) {
continue; continue;
} }
if (it.value()->name() == "edit_file" && !settings.enableEditFileTool()) {
continue;
}
const auto requiredPerms = it.value()->requiredPermissions(); const auto requiredPerms = it.value()->requiredPermissions();
bool hasPermission = true; bool hasPermission = true;

View File

@ -59,7 +59,6 @@ void ToolsManager::executeToolCall(
auto &queue = m_toolQueues[requestId]; auto &queue = m_toolQueues[requestId];
// Check if tool already exists in queue or completed
for (const auto &tool : queue.queue) { for (const auto &tool : queue.queue) {
if (tool.id == toolId) { if (tool.id == toolId) {
LOG_MESSAGE(QString("Tool %1 already in queue for request %2").arg(toolId, requestId)); LOG_MESSAGE(QString("Tool %1 already in queue for request %2").arg(toolId, requestId));
@ -73,15 +72,16 @@ void ToolsManager::executeToolCall(
return; return;
} }
// Add tool to queue QJsonObject modifiedInput = input;
PendingTool pendingTool{toolId, toolName, input, "", false}; modifiedInput["_request_id"] = requestId;
PendingTool pendingTool{toolId, toolName, modifiedInput, "", false};
queue.queue.append(pendingTool); queue.queue.append(pendingTool);
LOG_MESSAGE(QString("ToolsManager: Tool %1 added to queue (position %2)") LOG_MESSAGE(QString("ToolsManager: Tool %1 added to queue (position %2)")
.arg(toolName) .arg(toolName)
.arg(queue.queue.size())); .arg(queue.queue.size()));
// Start execution if not already running
if (!queue.isExecuting) { if (!queue.isExecuting) {
executeNextTool(requestId); executeNextTool(requestId);
} }
@ -95,7 +95,6 @@ void ToolsManager::executeNextTool(const QString &requestId)
auto &queue = m_toolQueues[requestId]; auto &queue = m_toolQueues[requestId];
// Check if queue is empty
if (queue.queue.isEmpty()) { if (queue.queue.isEmpty()) {
LOG_MESSAGE(QString("ToolsManager: All tools complete for request %1, emitting results") LOG_MESSAGE(QString("ToolsManager: All tools complete for request %1, emitting results")
.arg(requestId)); .arg(requestId));
@ -105,7 +104,6 @@ void ToolsManager::executeNextTool(const QString &requestId)
return; return;
} }
// Get next tool from queue
PendingTool tool = queue.queue.takeFirst(); PendingTool tool = queue.queue.takeFirst();
queue.isExecuting = true; queue.isExecuting = true;
@ -116,7 +114,6 @@ void ToolsManager::executeNextTool(const QString &requestId)
auto toolInstance = m_toolsFactory->getToolByName(tool.name); auto toolInstance = m_toolsFactory->getToolByName(tool.name);
if (!toolInstance) { if (!toolInstance) {
LOG_MESSAGE(QString("ToolsManager: Tool not found: %1").arg(tool.name)); 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.result = QString("Error: Tool not found: %1").arg(tool.name);
tool.complete = true; tool.complete = true;
queue.completed[tool.id] = tool; queue.completed[tool.id] = tool;
@ -124,7 +121,6 @@ void ToolsManager::executeNextTool(const QString &requestId)
return; return;
} }
// Store tool in completed map (will be updated when finished)
queue.completed[tool.id] = tool; queue.completed[tool.id] = tool;
m_toolHandler->executeToolAsync(requestId, tool.id, toolInstance, tool.input); m_toolHandler->executeToolAsync(requestId, tool.id, toolInstance, tool.input);
@ -176,7 +172,6 @@ void ToolsManager::onToolFinished(
.arg(success ? QString("completed") : QString("failed")) .arg(success ? QString("completed") : QString("failed"))
.arg(requestId)); .arg(requestId));
// Execute next tool in queue
executeNextTool(requestId); executeNextTool(requestId);
} }