mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-04-12 07:42:52 -04:00
feat: Add file suggestion edit tool and chat UI (#240)
* feat: Add settings for write to system tool access
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -75,3 +75,5 @@ CMakeLists.txt.user*
|
|||||||
|
|
||||||
/build
|
/build
|
||||||
/.qodeassist
|
/.qodeassist
|
||||||
|
/.cursor
|
||||||
|
/.vscode
|
||||||
@ -1,5 +1,7 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
list(APPEND CMAKE_PREFIX_PATH "/Users/palm1r/Qt/Qt Creator.app/Contents/Resources/lib/cmake/QtCreator")
|
||||||
|
|
||||||
project(QodeAssist)
|
project(QodeAssist)
|
||||||
|
|
||||||
set(CMAKE_AUTOMOC ON)
|
set(CMAKE_AUTOMOC ON)
|
||||||
@ -120,6 +122,7 @@ add_qtc_plugin(QodeAssist
|
|||||||
tools/ToolsManager.hpp tools/ToolsManager.cpp
|
tools/ToolsManager.hpp tools/ToolsManager.cpp
|
||||||
tools/SearchInProjectTool.hpp tools/SearchInProjectTool.cpp
|
tools/SearchInProjectTool.hpp tools/SearchInProjectTool.cpp
|
||||||
tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp
|
tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp
|
||||||
|
tools/EditProjectFileTool.hpp tools/EditProjectFileTool.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
|
||||||
|
|||||||
@ -18,6 +18,7 @@ qt_add_qml_module(QodeAssistChatView
|
|||||||
qml/parts/AttachedFilesPlace.qml
|
qml/parts/AttachedFilesPlace.qml
|
||||||
qml/parts/ErrorToast.qml
|
qml/parts/ErrorToast.qml
|
||||||
qml/ToolStatusItem.qml
|
qml/ToolStatusItem.qml
|
||||||
|
qml/FileEditChangesItem.qml
|
||||||
|
|
||||||
RESOURCES
|
RESOURCES
|
||||||
icons/attach-file-light.svg
|
icons/attach-file-light.svg
|
||||||
@ -34,6 +35,7 @@ qt_add_qml_module(QodeAssistChatView
|
|||||||
icons/window-unlock.svg
|
icons/window-unlock.svg
|
||||||
icons/chat-icon.svg
|
icons/chat-icon.svg
|
||||||
icons/chat-pause-icon.svg
|
icons/chat-pause-icon.svg
|
||||||
|
|
||||||
SOURCES
|
SOURCES
|
||||||
ChatWidget.hpp ChatWidget.cpp
|
ChatWidget.hpp ChatWidget.cpp
|
||||||
ChatModel.hpp ChatModel.cpp
|
ChatModel.hpp ChatModel.cpp
|
||||||
@ -44,6 +46,7 @@ qt_add_qml_module(QodeAssistChatView
|
|||||||
ChatSerializer.hpp ChatSerializer.cpp
|
ChatSerializer.hpp ChatSerializer.cpp
|
||||||
ChatView.hpp ChatView.cpp
|
ChatView.hpp ChatView.cpp
|
||||||
ChatData.hpp
|
ChatData.hpp
|
||||||
|
FileEditItem.hpp FileEditItem.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(QodeAssistChatView
|
target_link_libraries(QodeAssistChatView
|
||||||
@ -58,6 +61,7 @@ target_link_libraries(QodeAssistChatView
|
|||||||
QodeAssistSettings
|
QodeAssistSettings
|
||||||
Context
|
Context
|
||||||
QodeAssistUIControlsplugin
|
QodeAssistUIControlsplugin
|
||||||
|
QodeAssistLogger
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(QodeAssistChatView
|
target_include_directories(QodeAssistChatView
|
||||||
|
|||||||
@ -19,7 +19,8 @@
|
|||||||
|
|
||||||
#include "ChatModel.hpp"
|
#include "ChatModel.hpp"
|
||||||
#include <utils/aspects.h>
|
#include <utils/aspects.h>
|
||||||
#include <QtCore/qjsonobject.h>
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QtQml>
|
#include <QtQml>
|
||||||
|
|
||||||
#include "ChatAssistantSettings.hpp"
|
#include "ChatAssistantSettings.hpp"
|
||||||
@ -126,7 +127,6 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
|
|||||||
QRegularExpression codeBlockRegex("```(\\w*)\\n?([\\s\\S]*?)```");
|
QRegularExpression codeBlockRegex("```(\\w*)\\n?([\\s\\S]*?)```");
|
||||||
int lastIndex = 0;
|
int lastIndex = 0;
|
||||||
auto blockMatches = codeBlockRegex.globalMatch(content);
|
auto blockMatches = codeBlockRegex.globalMatch(content);
|
||||||
bool foundCodeBlock = blockMatches.hasNext();
|
|
||||||
|
|
||||||
while (blockMatches.hasNext()) {
|
while (blockMatches.hasNext()) {
|
||||||
auto match = blockMatches.next();
|
auto match = blockMatches.next();
|
||||||
@ -134,10 +134,19 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
|
|||||||
QString textBetween
|
QString textBetween
|
||||||
= content.mid(lastIndex, match.capturedStart() - lastIndex).trimmed();
|
= content.mid(lastIndex, match.capturedStart() - lastIndex).trimmed();
|
||||||
if (!textBetween.isEmpty()) {
|
if (!textBetween.isEmpty()) {
|
||||||
parts.append({MessagePartType::Text, textBetween, ""});
|
MessagePart part;
|
||||||
|
part.type = MessagePartType::Text;
|
||||||
|
part.text = textBetween;
|
||||||
|
parts.append(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parts.append({MessagePartType::Code, match.captured(2).trimmed(), match.captured(1)});
|
|
||||||
|
MessagePart codePart;
|
||||||
|
codePart.type = MessagePartType::Code;
|
||||||
|
codePart.text = match.captured(2).trimmed();
|
||||||
|
codePart.language = match.captured(1);
|
||||||
|
parts.append(codePart);
|
||||||
|
|
||||||
lastIndex = match.capturedEnd();
|
lastIndex = match.capturedEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,15 +159,22 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
|
|||||||
if (unclosedMatch.hasMatch()) {
|
if (unclosedMatch.hasMatch()) {
|
||||||
QString beforeCodeBlock = remainingText.left(unclosedMatch.capturedStart()).trimmed();
|
QString beforeCodeBlock = remainingText.left(unclosedMatch.capturedStart()).trimmed();
|
||||||
if (!beforeCodeBlock.isEmpty()) {
|
if (!beforeCodeBlock.isEmpty()) {
|
||||||
parts.append({MessagePartType::Text, beforeCodeBlock, ""});
|
MessagePart part;
|
||||||
|
part.type = MessagePartType::Text;
|
||||||
|
part.text = beforeCodeBlock;
|
||||||
|
parts.append(part);
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.append(
|
MessagePart codePart;
|
||||||
{MessagePartType::Code,
|
codePart.type = MessagePartType::Code;
|
||||||
unclosedMatch.captured(2).trimmed(),
|
codePart.text = unclosedMatch.captured(2).trimmed();
|
||||||
unclosedMatch.captured(1)});
|
codePart.language = unclosedMatch.captured(1);
|
||||||
|
parts.append(codePart);
|
||||||
} else if (!remainingText.isEmpty()) {
|
} else if (!remainingText.isEmpty()) {
|
||||||
parts.append({MessagePartType::Text, remainingText, ""});
|
MessagePart part;
|
||||||
|
part.type = MessagePartType::Text;
|
||||||
|
part.text = remainingText;
|
||||||
|
parts.append(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,17 +282,60 @@ void ChatModel::updateToolResult(
|
|||||||
.arg(requestId, toolId, toolName)
|
.arg(requestId, toolId, toolName)
|
||||||
.arg(result.length()));
|
.arg(result.length()));
|
||||||
|
|
||||||
|
bool toolMessageFound = false;
|
||||||
for (int i = m_messages.size() - 1; i >= 0; --i) {
|
for (int i = m_messages.size() - 1; i >= 0; --i) {
|
||||||
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
|
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
|
||||||
m_messages[i].content = toolName + "\n" + result;
|
m_messages[i].content = toolName + "\n" + result;
|
||||||
emit dataChanged(index(i), index(i));
|
emit dataChanged(index(i), index(i));
|
||||||
|
toolMessageFound = true;
|
||||||
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
|
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!toolMessageFound) {
|
||||||
LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!")
|
LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!")
|
||||||
.arg(requestId, toolId));
|
.arg(requestId, toolId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QString marker = "QODEASSIST_FILE_EDIT:";
|
||||||
|
if (result.contains(marker)) {
|
||||||
|
LOG_MESSAGE(QString("File edit marker detected in tool result"));
|
||||||
|
|
||||||
|
int markerPos = result.indexOf(marker);
|
||||||
|
int jsonStart = markerPos + marker.length();
|
||||||
|
|
||||||
|
if (jsonStart < result.length()) {
|
||||||
|
QString jsonStr = result.mid(jsonStart);
|
||||||
|
|
||||||
|
QJsonParseError parseError;
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError);
|
||||||
|
|
||||||
|
if (parseError.error != QJsonParseError::NoError) {
|
||||||
|
LOG_MESSAGE(QString("ERROR: Failed to parse file edit JSON at offset %1: %2")
|
||||||
|
.arg(parseError.offset)
|
||||||
|
.arg(parseError.errorString()));
|
||||||
|
} else if (!doc.isObject()) {
|
||||||
|
LOG_MESSAGE(
|
||||||
|
QString("ERROR: Parsed JSON is not an object, is array=%1").arg(doc.isArray()));
|
||||||
|
} else {
|
||||||
|
QJsonObject editData = doc.object();
|
||||||
|
QString editId = editData["edit_id"].toString();
|
||||||
|
|
||||||
|
LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId));
|
||||||
|
|
||||||
|
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||||
|
Message fileEditMsg;
|
||||||
|
fileEditMsg.role = ChatRole::FileEdit;
|
||||||
|
fileEditMsg.content = result;
|
||||||
|
fileEditMsg.id = editId.isEmpty() ? QString("edit_%1").arg(requestId) : editId;
|
||||||
|
m_messages.append(fileEditMsg);
|
||||||
|
endInsertRows();
|
||||||
|
|
||||||
|
LOG_MESSAGE(QString("Added FileEdit message with editId=%1").arg(editId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class ChatModel : public QAbstractListModel
|
|||||||
QML_ELEMENT
|
QML_ELEMENT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
enum ChatRole { System, User, Assistant, Tool };
|
enum ChatRole { System, User, Assistant, Tool, FileEdit };
|
||||||
Q_ENUM(ChatRole)
|
Q_ENUM(ChatRole)
|
||||||
|
|
||||||
enum Roles { RoleType = Qt::UserRole, Content, Attachments };
|
enum Roles { RoleType = Qt::UserRole, Content, Attachments };
|
||||||
|
|||||||
460
ChatView/FileEditItem.cpp
Normal file
460
ChatView/FileEditItem.cpp
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
/*
|
||||||
|
* 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 "FileEditItem.hpp"
|
||||||
|
|
||||||
|
#include "Logger.hpp"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QMutexLocker>
|
||||||
|
#include <QTextStream>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
QMutex FileEditItem::s_fileLockMutex;
|
||||||
|
QSet<QString> FileEditItem::s_lockedFiles;
|
||||||
|
|
||||||
|
FileEditItem::FileEditItem(QQuickItem *parent)
|
||||||
|
: QQuickItem(parent)
|
||||||
|
{}
|
||||||
|
|
||||||
|
void FileEditItem::parseFromContent(const QString &content)
|
||||||
|
{
|
||||||
|
static const QLatin1String marker(EDIT_MARKER);
|
||||||
|
int markerPos = content.indexOf(marker);
|
||||||
|
|
||||||
|
if (markerPos == -1) {
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: ERROR - no marker found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int jsonStart = markerPos + marker.size();
|
||||||
|
QString jsonStr = content.mid(jsonStart);
|
||||||
|
|
||||||
|
QJsonParseError parseError;
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError);
|
||||||
|
|
||||||
|
if (parseError.error != QJsonParseError::NoError) {
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: JSON parse error at offset %1: %2")
|
||||||
|
.arg(parseError.offset)
|
||||||
|
.arg(parseError.errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: ERROR - parsed JSON is not an object"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject editData = doc.object();
|
||||||
|
|
||||||
|
m_editId = editData["edit_id"].toString();
|
||||||
|
m_filePath = editData["file_path"].toString();
|
||||||
|
m_editMode = editData["mode"].toString();
|
||||||
|
m_originalContent = editData["original_content"].toString();
|
||||||
|
m_newContent = editData["new_content"].toString();
|
||||||
|
m_contextBefore = editData["context_before"].toString();
|
||||||
|
m_contextAfter = editData["context_after"].toString();
|
||||||
|
m_searchText = editData["search_text"].toString();
|
||||||
|
m_lineNumber = editData["line_number"].toInt(-1);
|
||||||
|
|
||||||
|
m_addedLines = m_newContent.split('\n').size();
|
||||||
|
m_removedLines = m_originalContent.split('\n').size();
|
||||||
|
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: parsed successfully, editId=%1, filePath=%2")
|
||||||
|
.arg(m_editId, m_filePath));
|
||||||
|
|
||||||
|
emit editIdChanged();
|
||||||
|
emit filePathChanged();
|
||||||
|
emit editModeChanged();
|
||||||
|
emit originalContentChanged();
|
||||||
|
emit newContentChanged();
|
||||||
|
emit addedLinesChanged();
|
||||||
|
emit removedLinesChanged();
|
||||||
|
|
||||||
|
applyEditInternal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::applyEdit()
|
||||||
|
{
|
||||||
|
applyEditInternal(false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::applyEditInternal(bool isAutomatic, int retryCount)
|
||||||
|
{
|
||||||
|
if (!isAutomatic && m_status != EditStatus::Reverted && m_status != EditStatus::Rejected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquireFileLock(m_filePath)) {
|
||||||
|
if (retryCount >= MAX_RETRY_COUNT) {
|
||||||
|
rejectWithError(QString("File %1 is locked, exceeded retry limit").arg(m_filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int retryDelay = isAutomatic ? AUTO_APPLY_RETRY_DELAY_MS : RETRY_DELAY_MS;
|
||||||
|
QTimer::singleShot(retryDelay, this, [this, isAutomatic, retryCount]() {
|
||||||
|
applyEditInternal(isAutomatic, retryCount + 1);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performApply();
|
||||||
|
releaseFileLock(m_filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::revertEdit()
|
||||||
|
{
|
||||||
|
if (m_status != EditStatus::Applied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquireFileLock(m_filePath)) {
|
||||||
|
QTimer::singleShot(RETRY_DELAY_MS, this, &FileEditItem::revertEdit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performRevert();
|
||||||
|
releaseFileLock(m_filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::performApply()
|
||||||
|
{
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: applying edit %1 to %2").arg(m_editId, m_filePath));
|
||||||
|
|
||||||
|
QString currentContent = readFile(m_filePath);
|
||||||
|
if (currentContent.isNull()) {
|
||||||
|
rejectWithError(QString("Failed to read file: %1").arg(m_filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
QString editedContent = applyEditToContent(currentContent, success);
|
||||||
|
if (!success) {
|
||||||
|
rejectWithError("Failed to apply edit: could not find context. File may have been modified.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeFile(m_filePath, editedContent)) {
|
||||||
|
rejectWithError(QString("Failed to write file: %1").arg(m_filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finishWithSuccess(EditStatus::Applied, QString("Successfully applied edit to: %1").arg(m_filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::performRevert()
|
||||||
|
{
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: reverting edit %1 for %2").arg(m_editId, m_filePath));
|
||||||
|
|
||||||
|
QString currentContent = readFile(m_filePath);
|
||||||
|
if (currentContent.isNull()) {
|
||||||
|
rejectWithError(QString("Failed to read file for revert: %1").arg(m_filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
QString revertedContent = applyReverseEdit(currentContent, success);
|
||||||
|
if (!success) {
|
||||||
|
rejectWithError("Failed to revert edit: could not find changes in current file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeFile(m_filePath, revertedContent)) {
|
||||||
|
rejectWithError(QString("Failed to write reverted file: %1").arg(m_filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finishWithSuccess(EditStatus::Reverted, QString("Successfully reverted edit to: %1").arg(m_filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::rejectWithError(const QString &errorMessage)
|
||||||
|
{
|
||||||
|
LOG_MESSAGE(errorMessage);
|
||||||
|
setStatus(EditStatus::Rejected);
|
||||||
|
setStatusMessage(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::finishWithSuccess(EditStatus status, const QString &message)
|
||||||
|
{
|
||||||
|
LOG_MESSAGE(message);
|
||||||
|
setStatus(status);
|
||||||
|
setStatusMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::setStatus(EditStatus status)
|
||||||
|
{
|
||||||
|
if (m_status == status)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_status = status;
|
||||||
|
emit statusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::setStatusMessage(const QString &message)
|
||||||
|
{
|
||||||
|
if (m_statusMessage == message)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_statusMessage = message;
|
||||||
|
emit statusMessageChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileEditItem::writeFile(const QString &filePath, const QString &content)
|
||||||
|
{
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||||
|
LOG_MESSAGE(QString("Could not open file for writing: %1").arg(filePath));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream stream(&file);
|
||||||
|
stream.setAutoDetectUnicode(true);
|
||||||
|
stream << content;
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (stream.status() != QTextStream::Ok) {
|
||||||
|
LOG_MESSAGE(QString("Error writing to file: %1").arg(filePath));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FileEditItem::readFile(const QString &filePath)
|
||||||
|
{
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
LOG_MESSAGE(QString("Could not open file for reading: %1").arg(filePath));
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream stream(&file);
|
||||||
|
stream.setAutoDetectUnicode(true);
|
||||||
|
QString content = stream.readAll();
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FileEditItem::applyEditToContent(const QString &content, bool &success)
|
||||||
|
{
|
||||||
|
success = false;
|
||||||
|
QStringList lines = content.split('\n');
|
||||||
|
|
||||||
|
if (m_editMode == "replace") {
|
||||||
|
QString searchPattern = m_contextBefore + m_searchText + m_contextAfter;
|
||||||
|
int pos = content.indexOf(searchPattern);
|
||||||
|
|
||||||
|
if (pos == -1 && !m_contextBefore.isEmpty()) {
|
||||||
|
pos = content.indexOf(m_searchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos != -1) {
|
||||||
|
QString result = content;
|
||||||
|
int searchPos = result.indexOf(m_searchText, pos);
|
||||||
|
if (searchPos != -1) {
|
||||||
|
result.replace(searchPos, m_searchText.length(), m_newContent);
|
||||||
|
success = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
|
||||||
|
} else if (m_editMode == "insert_before" || m_editMode == "insert_after") {
|
||||||
|
int targetLine = -1;
|
||||||
|
|
||||||
|
if (!m_contextBefore.isEmpty() || !m_contextAfter.isEmpty()) {
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
bool contextMatches = true;
|
||||||
|
|
||||||
|
if (!m_contextBefore.isEmpty()) {
|
||||||
|
QStringList beforeLines = m_contextBefore.split('\n');
|
||||||
|
if (i >= beforeLines.size()) {
|
||||||
|
bool allMatch = true;
|
||||||
|
for (int j = 0; j < beforeLines.size(); ++j) {
|
||||||
|
if (lines[i - beforeLines.size() + j].trimmed()
|
||||||
|
!= beforeLines[j].trimmed()) {
|
||||||
|
allMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allMatch)
|
||||||
|
contextMatches = false;
|
||||||
|
} else {
|
||||||
|
contextMatches = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextMatches && !m_contextAfter.isEmpty()) {
|
||||||
|
QStringList afterLines = m_contextAfter.split('\n');
|
||||||
|
if (i + afterLines.size() < lines.size()) {
|
||||||
|
bool allMatch = true;
|
||||||
|
for (int j = 0; j < afterLines.size(); ++j) {
|
||||||
|
if (lines[i + 1 + j].trimmed() != afterLines[j].trimmed()) {
|
||||||
|
allMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allMatch)
|
||||||
|
contextMatches = false;
|
||||||
|
} else {
|
||||||
|
contextMatches = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextMatches && targetLine == -1) {
|
||||||
|
targetLine = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLine == -1 && m_lineNumber > 0 && m_lineNumber <= lines.size()) {
|
||||||
|
targetLine = m_lineNumber - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLine != -1) {
|
||||||
|
if (m_editMode == "insert_before") {
|
||||||
|
lines.insert(targetLine, m_newContent);
|
||||||
|
} else {
|
||||||
|
lines.insert(targetLine + 1, m_newContent);
|
||||||
|
}
|
||||||
|
success = true;
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
|
||||||
|
} else if (m_editMode == "append") {
|
||||||
|
success = true;
|
||||||
|
return content + (content.endsWith('\n') ? "" : "\n") + m_newContent + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FileEditItem::applyReverseEdit(const QString &content, bool &success)
|
||||||
|
{
|
||||||
|
success = false;
|
||||||
|
QStringList lines = content.split('\n');
|
||||||
|
|
||||||
|
if (m_editMode == "replace") {
|
||||||
|
int pos = content.indexOf(m_newContent);
|
||||||
|
if (pos != -1) {
|
||||||
|
QString result = content;
|
||||||
|
result.replace(pos, m_newContent.length(), m_originalContent);
|
||||||
|
success = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
|
||||||
|
} else if (m_editMode == "insert_before" || m_editMode == "insert_after") {
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
if (lines[i].trimmed() == m_newContent.trimmed()) {
|
||||||
|
bool contextMatches = true;
|
||||||
|
|
||||||
|
if (!m_contextBefore.isEmpty()) {
|
||||||
|
QStringList beforeLines = m_contextBefore.split('\n');
|
||||||
|
if (i >= beforeLines.size()) {
|
||||||
|
for (int j = 0; j < beforeLines.size(); ++j) {
|
||||||
|
if (lines[i - beforeLines.size() + j].trimmed() != beforeLines[j].trimmed()) {
|
||||||
|
contextMatches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contextMatches = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextMatches && !m_contextAfter.isEmpty()) {
|
||||||
|
QStringList afterLines = m_contextAfter.split('\n');
|
||||||
|
if (i + 1 + afterLines.size() <= lines.size()) {
|
||||||
|
for (int j = 0; j < afterLines.size(); ++j) {
|
||||||
|
if (lines[i + 1 + j].trimmed() != afterLines[j].trimmed()) {
|
||||||
|
contextMatches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contextMatches = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextMatches) {
|
||||||
|
lines.removeAt(i);
|
||||||
|
success = true;
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
|
||||||
|
} else if (m_editMode == "append") {
|
||||||
|
QString suffix1 = m_newContent + "\n";
|
||||||
|
QString suffix2 = "\n" + m_newContent + "\n";
|
||||||
|
|
||||||
|
if (content.endsWith(suffix1)) {
|
||||||
|
QString result = content.left(content.length() - suffix1.length());
|
||||||
|
success = true;
|
||||||
|
return result;
|
||||||
|
} else if (content.endsWith(suffix2)) {
|
||||||
|
QString result = content.left(content.length() - suffix2.length()) + "\n";
|
||||||
|
success = true;
|
||||||
|
return result;
|
||||||
|
} else if (content.endsWith(m_newContent)) {
|
||||||
|
QString result = content.left(content.length() - m_newContent.length());
|
||||||
|
success = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileEditItem::acquireFileLock(const QString &filePath)
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&s_fileLockMutex);
|
||||||
|
|
||||||
|
if (s_lockedFiles.contains(filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_lockedFiles.insert(filePath);
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: acquired lock for %1").arg(filePath));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileEditItem::releaseFileLock(const QString &filePath)
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&s_fileLockMutex);
|
||||||
|
|
||||||
|
if (s_lockedFiles.remove(filePath)) {
|
||||||
|
LOG_MESSAGE(QString("FileEditItem: released lock for %1").arg(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
122
ChatView/FileEditItem.hpp
Normal file
122
ChatView/FileEditItem.hpp
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* 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 <QMutex>
|
||||||
|
#include <QQuickItem>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
#include <QtQmlIntegration>
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class FileEditItem : public QQuickItem
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum class EditStatus {
|
||||||
|
Pending,
|
||||||
|
Applied,
|
||||||
|
Rejected,
|
||||||
|
Reverted
|
||||||
|
};
|
||||||
|
Q_ENUM(EditStatus)
|
||||||
|
|
||||||
|
static constexpr const char *EDIT_MARKER = "QODEASSIST_FILE_EDIT:";
|
||||||
|
static constexpr int RETRY_DELAY_MS = 100;
|
||||||
|
static constexpr int AUTO_APPLY_RETRY_DELAY_MS = 50;
|
||||||
|
static constexpr int MAX_RETRY_COUNT = 10;
|
||||||
|
|
||||||
|
Q_PROPERTY(QString editId READ editId NOTIFY editIdChanged FINAL)
|
||||||
|
Q_PROPERTY(QString filePath READ filePath NOTIFY filePathChanged FINAL)
|
||||||
|
Q_PROPERTY(QString editMode READ editMode NOTIFY editModeChanged FINAL)
|
||||||
|
Q_PROPERTY(QString originalContent READ originalContent NOTIFY originalContentChanged FINAL)
|
||||||
|
Q_PROPERTY(QString newContent READ newContent NOTIFY newContentChanged FINAL)
|
||||||
|
Q_PROPERTY(int addedLines READ addedLines NOTIFY addedLinesChanged FINAL)
|
||||||
|
Q_PROPERTY(int removedLines READ removedLines NOTIFY removedLinesChanged FINAL)
|
||||||
|
Q_PROPERTY(EditStatus status READ status NOTIFY statusChanged FINAL)
|
||||||
|
Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged FINAL)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit FileEditItem(QQuickItem *parent = nullptr);
|
||||||
|
|
||||||
|
QString editId() const { return m_editId; }
|
||||||
|
QString filePath() const { return m_filePath; }
|
||||||
|
QString editMode() const { return m_editMode; }
|
||||||
|
QString originalContent() const { return m_originalContent; }
|
||||||
|
QString newContent() const { return m_newContent; }
|
||||||
|
int addedLines() const { return m_addedLines; }
|
||||||
|
int removedLines() const { return m_removedLines; }
|
||||||
|
EditStatus status() const { return m_status; }
|
||||||
|
QString statusMessage() const { return m_statusMessage; }
|
||||||
|
|
||||||
|
Q_INVOKABLE void parseFromContent(const QString &content);
|
||||||
|
Q_INVOKABLE void applyEdit();
|
||||||
|
Q_INVOKABLE void revertEdit();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void editIdChanged();
|
||||||
|
void filePathChanged();
|
||||||
|
void editModeChanged();
|
||||||
|
void originalContentChanged();
|
||||||
|
void newContentChanged();
|
||||||
|
void addedLinesChanged();
|
||||||
|
void removedLinesChanged();
|
||||||
|
void statusChanged();
|
||||||
|
void statusMessageChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void setStatus(EditStatus status);
|
||||||
|
void setStatusMessage(const QString &message);
|
||||||
|
void applyEditInternal(bool isAutomatic, int retryCount = 0);
|
||||||
|
void performApply();
|
||||||
|
void performRevert();
|
||||||
|
void rejectWithError(const QString &errorMessage);
|
||||||
|
void finishWithSuccess(EditStatus status, const QString &message);
|
||||||
|
|
||||||
|
bool writeFile(const QString &filePath, const QString &content);
|
||||||
|
QString readFile(const QString &filePath);
|
||||||
|
QString applyEditToContent(const QString &content, bool &success);
|
||||||
|
QString applyReverseEdit(const QString &content, bool &success);
|
||||||
|
|
||||||
|
static bool acquireFileLock(const QString &filePath);
|
||||||
|
static void releaseFileLock(const QString &filePath);
|
||||||
|
static QMutex s_fileLockMutex;
|
||||||
|
static QSet<QString> s_lockedFiles;
|
||||||
|
|
||||||
|
QString m_editId;
|
||||||
|
QString m_filePath;
|
||||||
|
QString m_editMode;
|
||||||
|
QString m_originalContent;
|
||||||
|
QString m_newContent;
|
||||||
|
QString m_contextBefore;
|
||||||
|
QString m_contextAfter;
|
||||||
|
QString m_searchText;
|
||||||
|
int m_lineNumber = -1;
|
||||||
|
int m_addedLines = 0;
|
||||||
|
int m_removedLines = 0;
|
||||||
|
EditStatus m_status = EditStatus::Pending;
|
||||||
|
QString m_statusMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
|
|
||||||
254
ChatView/qml/FileEditChangesItem.qml
Normal file
254
ChatView/qml/FileEditChangesItem.qml
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
/*
|
||||||
|
* 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.Layouts
|
||||||
|
import ChatView
|
||||||
|
import UIControls
|
||||||
|
import "./parts"
|
||||||
|
|
||||||
|
FileEditItem {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
implicitHeight: fileEditView.implicitHeight
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.parseFromContent(model.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 var originalLines: originalContent.split('\n')
|
||||||
|
readonly property var newLines: newContent.split('\n')
|
||||||
|
readonly property string firstOriginalLine: originalLines[0] || ""
|
||||||
|
readonly property string firstNewLine: newLines[0] || ""
|
||||||
|
readonly property bool hasMultipleOriginalLines: originalLines.length > 1
|
||||||
|
readonly property bool hasMultipleNewLines: newLines.length > 1
|
||||||
|
|
||||||
|
readonly property bool isPending: status === FileEditItem.Pending
|
||||||
|
readonly property bool isApplied: status === FileEditItem.Applied
|
||||||
|
readonly property bool isReverted: status === FileEditItem.Reverted
|
||||||
|
readonly property bool isRejected: status === FileEditItem.Rejected
|
||||||
|
|
||||||
|
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: palette.mid
|
||||||
|
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 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 (isApplied) return appliedColor
|
||||||
|
if (isReverted) return revertedColor
|
||||||
|
if (isRejected) return rejectedColor
|
||||||
|
return pendingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property color statusBgColor: {
|
||||||
|
if (isApplied) return appliedBgColor
|
||||||
|
if (isReverted) return revertedBgColor
|
||||||
|
if (isRejected) return rejectedBgColor
|
||||||
|
return palette.button
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string statusText: {
|
||||||
|
if (isApplied) return qsTr("APPLIED")
|
||||||
|
if (isReverted) return qsTr("REVERTED")
|
||||||
|
if (isRejected) return qsTr("REJECTED")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: fileEditView
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
implicitHeight: expanded ? headerArea.height + contentColumn.height + root.contentBottomPadding
|
||||||
|
: headerArea.height
|
||||||
|
radius: root.borderRadius
|
||||||
|
|
||||||
|
property bool expanded: false
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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: "implicitHeight,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: qsTr("File Edit: %1 (+%2 -%3)")
|
||||||
|
.arg(root.filePath)
|
||||||
|
.arg(root.addedLines)
|
||||||
|
.arg(root.removedLines)
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.bold: true
|
||||||
|
color: palette.text
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: fileEditView.expanded ? "▼" : "▶"
|
||||||
|
font.pixelSize: 10
|
||||||
|
color: palette.mid
|
||||||
|
}
|
||||||
|
|
||||||
|
Badge {
|
||||||
|
visible: !root.isPending
|
||||||
|
text: root.statusText
|
||||||
|
color: root.statusBgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: actionButtons
|
||||||
|
|
||||||
|
anchors {
|
||||||
|
right: parent.right
|
||||||
|
rightMargin: 5
|
||||||
|
verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
QoAButton {
|
||||||
|
text: qsTr("Apply")
|
||||||
|
enabled: root.isReverted || root.isRejected
|
||||||
|
visible: !root.isApplied
|
||||||
|
onClicked: root.applyEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
QoAButton {
|
||||||
|
text: qsTr("Revert")
|
||||||
|
enabled: root.isApplied
|
||||||
|
visible: !root.isReverted && !root.isRejected
|
||||||
|
onClicked: root.revertEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: contentColumn
|
||||||
|
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
top: headerArea.bottom
|
||||||
|
margins: root.contentMargin
|
||||||
|
}
|
||||||
|
spacing: 4
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Old: " + root.firstOriginalLine + (root.hasMultipleOriginalLines ? "..." : "")
|
||||||
|
font.family: root.codeFontFamily
|
||||||
|
font.pixelSize: root.codeFontSize
|
||||||
|
color: Qt.rgba(1, 0.2, 0.2, 0.9)
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "New: " + root.firstNewLine + (root.hasMultipleNewLines ? "..." : "")
|
||||||
|
font.family: root.codeFontFamily
|
||||||
|
font.pixelSize: root.codeFontSize
|
||||||
|
color: Qt.rgba(0.2, 0.8, 0.2, 0.9)
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.statusMessage.length > 0
|
||||||
|
text: root.statusMessage
|
||||||
|
font.pixelSize: 11
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -102,7 +102,15 @@ ChatRootView {
|
|||||||
|
|
||||||
width: ListView.view.width - scroll.width
|
width: ListView.view.width - scroll.width
|
||||||
|
|
||||||
sourceComponent: model.roleType === ChatModel.Tool ? toolMessageComponent : chatItemComponent
|
sourceComponent: {
|
||||||
|
if (model.roleType === ChatModel.Tool) {
|
||||||
|
return toolMessageComponent
|
||||||
|
} else if (model.roleType === ChatModel.FileEdit) {
|
||||||
|
return fileEditSuggestionComponent
|
||||||
|
} else {
|
||||||
|
return chatItemComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header: Item {
|
header: Item {
|
||||||
@ -128,6 +136,7 @@ ChatRootView {
|
|||||||
id: chatItemComponent
|
id: chatItemComponent
|
||||||
|
|
||||||
ChatItem {
|
ChatItem {
|
||||||
|
id: chatItemInstance
|
||||||
msgModel: root.chatModel.processMessageContent(model.content)
|
msgModel: root.chatModel.processMessageContent(model.content)
|
||||||
messageAttachments: model.attachments
|
messageAttachments: model.attachments
|
||||||
isUserMessage: model.roleType === ChatModel.User
|
isUserMessage: model.roleType === ChatModel.User
|
||||||
@ -153,6 +162,16 @@ ChatRootView {
|
|||||||
toolContent: model.content
|
toolContent: model.content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: fileEditSuggestionComponent
|
||||||
|
|
||||||
|
FileEditChangesItem {
|
||||||
|
id: fileEditItem
|
||||||
|
|
||||||
|
width: chatListView.width - 10
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|||||||
@ -220,6 +220,12 @@ GeneralSettings::GeneralSettings()
|
|||||||
Tr::tr("Allow tools to read files from disk (project files, open editors)"));
|
Tr::tr("Allow tools to read files from disk (project files, open editors)"));
|
||||||
allowFileSystemRead.setDefaultValue(true);
|
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);
|
||||||
|
|
||||||
readSettings();
|
readSettings();
|
||||||
|
|
||||||
Logger::instance().setLoggingEnabled(enableLogging());
|
Logger::instance().setLoggingEnabled(enableLogging());
|
||||||
@ -265,7 +271,9 @@ GeneralSettings::GeneralSettings()
|
|||||||
|
|
||||||
auto caGroup = Group{
|
auto caGroup = Group{
|
||||||
title(TrConstants::CHAT_ASSISTANT),
|
title(TrConstants::CHAT_ASSISTANT),
|
||||||
Column{caGrid, Column{useTools, allowFileSystemRead}, caTemplateDescription}};
|
Column{caGrid,
|
||||||
|
Column{useTools, allowFileSystemRead, allowFileSystemWrite},
|
||||||
|
caTemplateDescription}};
|
||||||
|
|
||||||
auto rootLayout = Column{
|
auto rootLayout = Column{
|
||||||
Row{enableQodeAssist, Stretch{1}, Row{checkUpdate, resetToDefaults}},
|
Row{enableQodeAssist, Stretch{1}, Row{checkUpdate, resetToDefaults}},
|
||||||
@ -511,6 +519,7 @@ void GeneralSettings::resetPageToDefaults()
|
|||||||
resetAspect(caCustomEndpoint);
|
resetAspect(caCustomEndpoint);
|
||||||
resetAspect(useTools);
|
resetAspect(useTools);
|
||||||
resetAspect(allowFileSystemRead);
|
resetAspect(allowFileSystemRead);
|
||||||
|
resetAspect(allowFileSystemWrite);
|
||||||
writeSettings();
|
writeSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,6 +102,7 @@ public:
|
|||||||
|
|
||||||
Utils::BoolAspect useTools{this};
|
Utils::BoolAspect useTools{this};
|
||||||
Utils::BoolAspect allowFileSystemRead{this};
|
Utils::BoolAspect allowFileSystemRead{this};
|
||||||
|
Utils::BoolAspect allowFileSystemWrite{this};
|
||||||
|
|
||||||
Utils::StringAspect caTemplateDescription{this};
|
Utils::StringAspect caTemplateDescription{this};
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,7 @@ const char CA_ENABLE_CHAT_IN_BOTTOM_TOOLBAR[] = "QodeAssist.caEnableChatInBottom
|
|||||||
const char CA_ENABLE_CHAT_IN_NAVIGATION_PANEL[] = "QodeAssist.caEnableChatInNavigationPanel";
|
const char CA_ENABLE_CHAT_IN_NAVIGATION_PANEL[] = "QodeAssist.caEnableChatInNavigationPanel";
|
||||||
const char CA_USE_TOOLS[] = "QodeAssist.caUseTools";
|
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 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";
|
||||||
|
|||||||
383
tools/EditProjectFileTool.cpp
Normal file
383
tools/EditProjectFileTool.cpp
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
/*
|
||||||
|
* 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 "EditProjectFileTool.hpp"
|
||||||
|
|
||||||
|
#include <coreplugin/documentmanager.h>
|
||||||
|
#include <logger/Logger.hpp>
|
||||||
|
#include <projectexplorer/project.h>
|
||||||
|
#include <projectexplorer/projectmanager.h>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
#include <QTextStream>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
|
namespace QodeAssist::Tools {
|
||||||
|
|
||||||
|
EditProjectFileTool::EditProjectFileTool(QObject *parent)
|
||||||
|
: BaseTool(parent)
|
||||||
|
, m_ignoreManager(new Context::IgnoreManager(this))
|
||||||
|
{}
|
||||||
|
|
||||||
|
QString EditProjectFileTool::name() const
|
||||||
|
{
|
||||||
|
return "edit_project_file";
|
||||||
|
}
|
||||||
|
|
||||||
|
QString EditProjectFileTool::stringName() const
|
||||||
|
{
|
||||||
|
return {"Editing project file"};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString EditProjectFileTool::description() const
|
||||||
|
{
|
||||||
|
return "Edit the content of a specific file in the current project. This tool proposes file "
|
||||||
|
"changes that will be shown to the user for approval.\n\n"
|
||||||
|
"**Edit Modes (choose the most precise mode for the change):**\n"
|
||||||
|
"1. 'replace' - Replace exact multi-line text blocks (use for substantial changes)\n"
|
||||||
|
"2. 'insert_before' - Insert new lines before a specific line number (preferred for "
|
||||||
|
"adding imports, comments, or new code)\n"
|
||||||
|
"3. 'insert_after' - Insert new lines after a specific line number (preferred for "
|
||||||
|
"adding code after existing lines)\n"
|
||||||
|
"4. 'append' - Append new content to the end of file\n\n"
|
||||||
|
"**Best Practices:**\n"
|
||||||
|
"- For single-line changes: use 'replace' mode with exact line content\n"
|
||||||
|
"- For adding new lines: prefer 'insert_before' or 'insert_after' over 'replace'\n"
|
||||||
|
"- For adding imports/includes: use 'insert_after' at the end of import section\n"
|
||||||
|
"- For multi-line refactoring: use 'replace' mode\n"
|
||||||
|
"- Keep search_text as small as possible while remaining unique\n\n"
|
||||||
|
"**Input Parameters:**\n"
|
||||||
|
"- 'filename' (required): Name or relative path of the file to edit\n"
|
||||||
|
"- 'mode' (required): Edit mode - 'replace', 'insert_before', 'insert_after', or "
|
||||||
|
"'append'\n"
|
||||||
|
"- 'search_text' (optional): Exact text to find (required for 'replace' mode)\n"
|
||||||
|
"- 'new_text' (required): New text to insert or use as replacement\n"
|
||||||
|
"- 'line_number' (optional): Line number for insert operations (required for "
|
||||||
|
"'insert_before' and 'insert_after' modes)\n\n"
|
||||||
|
"**Usage Examples:**\n"
|
||||||
|
"- Single line fix: {\"filename\": \"main.cpp\", \"mode\": \"replace\", "
|
||||||
|
"\"search_text\": \" Test test = new Test();\", \"new_text\": \" Test test;\"}\n"
|
||||||
|
"- Add import: {\"filename\": \"main.cpp\", \"mode\": \"insert_after\", "
|
||||||
|
"\"line_number\": 4, \"new_text\": \"#include <memory>\"}\n"
|
||||||
|
"- Add function: {\"filename\": \"main.cpp\", \"mode\": \"insert_before\", "
|
||||||
|
"\"line_number\": 20, \"new_text\": \"void helper() {\\n // code\\n}\\n\"}\n\n"
|
||||||
|
"**Important Notes:**\n"
|
||||||
|
"- Files excluded by .qodeassistignore cannot be edited\n"
|
||||||
|
"- Changes will be shown to user with diff for approval before applying\n"
|
||||||
|
"- For 'replace' mode, search_text must match exactly (including whitespace)\n"
|
||||||
|
"- Line numbers are 1-based\n"
|
||||||
|
"- User will see a visual diff and can approve or reject the change";
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject EditProjectFileTool::getDefinition(LLMCore::ToolSchemaFormat format) const
|
||||||
|
{
|
||||||
|
QJsonObject properties;
|
||||||
|
|
||||||
|
QJsonObject filenameProperty;
|
||||||
|
filenameProperty["type"] = "string";
|
||||||
|
filenameProperty["description"] = "The filename or relative path to edit";
|
||||||
|
properties["filename"] = filenameProperty;
|
||||||
|
|
||||||
|
QJsonObject modeProperty;
|
||||||
|
modeProperty["type"] = "string";
|
||||||
|
modeProperty["description"]
|
||||||
|
= "Edit mode: 'replace', 'insert_before', 'insert_after', or 'append'";
|
||||||
|
QJsonArray modeEnum;
|
||||||
|
modeEnum.append("replace");
|
||||||
|
modeEnum.append("insert_before");
|
||||||
|
modeEnum.append("insert_after");
|
||||||
|
modeEnum.append("append");
|
||||||
|
modeProperty["enum"] = modeEnum;
|
||||||
|
properties["mode"] = modeProperty;
|
||||||
|
|
||||||
|
QJsonObject searchTextProperty;
|
||||||
|
searchTextProperty["type"] = "string";
|
||||||
|
searchTextProperty["description"]
|
||||||
|
= "Text to search for and replace (required for 'replace' mode)";
|
||||||
|
properties["search_text"] = searchTextProperty;
|
||||||
|
|
||||||
|
QJsonObject newTextProperty;
|
||||||
|
newTextProperty["type"] = "string";
|
||||||
|
newTextProperty["description"] = "New text to insert or use as replacement";
|
||||||
|
properties["new_text"] = newTextProperty;
|
||||||
|
|
||||||
|
QJsonObject lineNumberProperty;
|
||||||
|
lineNumberProperty["type"] = "integer";
|
||||||
|
lineNumberProperty["description"]
|
||||||
|
= "Line number for insert operations (1-based, required for insert modes)";
|
||||||
|
properties["line_number"] = lineNumberProperty;
|
||||||
|
|
||||||
|
QJsonObject definition;
|
||||||
|
definition["type"] = "object";
|
||||||
|
definition["properties"] = properties;
|
||||||
|
|
||||||
|
QJsonArray required;
|
||||||
|
required.append("filename");
|
||||||
|
required.append("mode");
|
||||||
|
required.append("new_text");
|
||||||
|
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 EditProjectFileTool::requiredPermissions() const
|
||||||
|
{
|
||||||
|
return LLMCore::ToolPermissions(LLMCore::ToolPermission::FileSystemRead)
|
||||||
|
| LLMCore::ToolPermissions(LLMCore::ToolPermission::FileSystemWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
QFuture<QString> EditProjectFileTool::executeAsync(const QJsonObject &input)
|
||||||
|
{
|
||||||
|
return QtConcurrent::run([this, input]() -> QString {
|
||||||
|
QString filename = input["filename"].toString();
|
||||||
|
if (filename.isEmpty()) {
|
||||||
|
QString error = "Error: filename parameter is required";
|
||||||
|
throw std::invalid_argument(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString modeStr = input["mode"].toString();
|
||||||
|
if (modeStr.isEmpty()) {
|
||||||
|
QString error = "Error: mode parameter is required";
|
||||||
|
throw std::invalid_argument(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
EditMode mode;
|
||||||
|
if (modeStr == "replace") {
|
||||||
|
mode = EditMode::Replace;
|
||||||
|
} else if (modeStr == "insert_before") {
|
||||||
|
mode = EditMode::InsertBefore;
|
||||||
|
} else if (modeStr == "insert_after") {
|
||||||
|
mode = EditMode::InsertAfter;
|
||||||
|
} else if (modeStr == "append") {
|
||||||
|
mode = EditMode::AppendToEnd;
|
||||||
|
} else {
|
||||||
|
QString error = QString("Error: Invalid mode '%1'. Must be one of: replace, "
|
||||||
|
"insert_before, insert_after, append")
|
||||||
|
.arg(modeStr);
|
||||||
|
throw std::invalid_argument(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString newText = input["new_text"].toString();
|
||||||
|
if (newText.isEmpty()) {
|
||||||
|
QString error = "Error: new_text parameter is required";
|
||||||
|
throw std::invalid_argument(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString searchText = input["search_text"].toString();
|
||||||
|
if (mode == EditMode::Replace && searchText.isEmpty()) {
|
||||||
|
QString error = "Error: search_text parameter is required for replace mode";
|
||||||
|
throw std::invalid_argument(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
int lineNumber = input["line_number"].toInt(0);
|
||||||
|
if ((mode == EditMode::InsertBefore || mode == EditMode::InsertAfter) && lineNumber <= 0) {
|
||||||
|
QString error = "Error: line_number parameter is required for insert modes and must "
|
||||||
|
"be greater than 0";
|
||||||
|
throw std::invalid_argument(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString filePath = findFileInProject(filename);
|
||||||
|
if (filePath.isEmpty()) {
|
||||||
|
QString error = QString("Error: File '%1' not found in project").arg(filename);
|
||||||
|
throw std::runtime_error(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto project = ProjectExplorer::ProjectManager::projectForFile(
|
||||||
|
Utils::FilePath::fromString(filePath));
|
||||||
|
if (project && m_ignoreManager->shouldIgnore(filePath, project)) {
|
||||||
|
QString error
|
||||||
|
= QString("Error: File '%1' is excluded by .qodeassistignore and cannot be edited")
|
||||||
|
.arg(filename);
|
||||||
|
throw std::runtime_error(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString originalContent = readFileContent(filePath);
|
||||||
|
if (originalContent.isNull()) {
|
||||||
|
QString error = QString("Error: Could not read file '%1'").arg(filePath);
|
||||||
|
throw std::runtime_error(error.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_MESSAGE(QString("Prepared file edit: %1 (mode: %2)").arg(filePath, modeStr));
|
||||||
|
|
||||||
|
QString editId = QString("edit_%1_%2")
|
||||||
|
.arg(QDateTime::currentMSecsSinceEpoch())
|
||||||
|
.arg(QRandomGenerator::global()->generate());
|
||||||
|
|
||||||
|
QString contextBefore, contextAfter;
|
||||||
|
extractContext(originalContent, mode, searchText, lineNumber, contextBefore, contextAfter);
|
||||||
|
|
||||||
|
QJsonObject result;
|
||||||
|
result["type"] = "file_edit";
|
||||||
|
result["edit_id"] = editId;
|
||||||
|
result["file_path"] = filePath;
|
||||||
|
result["mode"] = modeStr;
|
||||||
|
result["original_content"] = (mode == EditMode::Replace) ? searchText : "";
|
||||||
|
result["new_content"] = newText;
|
||||||
|
result["context_before"] = contextBefore;
|
||||||
|
result["context_after"] = contextAfter;
|
||||||
|
result["search_text"] = searchText;
|
||||||
|
result["line_number"] = lineNumber;
|
||||||
|
|
||||||
|
QJsonDocument doc(result);
|
||||||
|
return QString("QODEASSIST_FILE_EDIT:%1")
|
||||||
|
.arg(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QString EditProjectFileTool::findFileInProject(const QString &fileName) const
|
||||||
|
{
|
||||||
|
QList<ProjectExplorer::Project *> projects = ProjectExplorer::ProjectManager::projects();
|
||||||
|
if (projects.isEmpty()) {
|
||||||
|
LOG_MESSAGE("No projects found");
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileMatch
|
||||||
|
{
|
||||||
|
QString path;
|
||||||
|
int priority; // 1 = exact filename, 2 = ends with, 3 = contains
|
||||||
|
};
|
||||||
|
|
||||||
|
QVector<FileMatch> matches;
|
||||||
|
|
||||||
|
for (auto project : projects) {
|
||||||
|
if (!project)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Utils::FilePaths projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
|
||||||
|
|
||||||
|
for (const auto &projectFile : std::as_const(projectFiles)) {
|
||||||
|
QString absolutePath = projectFile.path();
|
||||||
|
|
||||||
|
if (m_ignoreManager->shouldIgnore(absolutePath, project)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString baseName = projectFile.fileName();
|
||||||
|
|
||||||
|
if (baseName == fileName) {
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectFile.endsWith(fileName)) {
|
||||||
|
matches.append({absolutePath, 2});
|
||||||
|
} else if (baseName.contains(fileName, Qt::CaseInsensitive)) {
|
||||||
|
matches.append({absolutePath, 3});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matches.isEmpty()) {
|
||||||
|
std::sort(matches.begin(), matches.end(), [](const FileMatch &a, const FileMatch &b) {
|
||||||
|
return a.priority < b.priority;
|
||||||
|
});
|
||||||
|
return matches.first().path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString EditProjectFileTool::readFileContent(const QString &filePath) const
|
||||||
|
{
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
LOG_MESSAGE(QString("Could not open file for reading: %1").arg(filePath));
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream stream(&file);
|
||||||
|
stream.setAutoDetectUnicode(true);
|
||||||
|
QString content = stream.readAll();
|
||||||
|
file.close();
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditProjectFileTool::extractContext(
|
||||||
|
const QString &content,
|
||||||
|
EditMode mode,
|
||||||
|
const QString &searchText,
|
||||||
|
int lineNumber,
|
||||||
|
QString &contextBefore,
|
||||||
|
QString &contextAfter,
|
||||||
|
int contextLines) const
|
||||||
|
{
|
||||||
|
contextBefore.clear();
|
||||||
|
contextAfter.clear();
|
||||||
|
|
||||||
|
QStringList lines = content.split('\n');
|
||||||
|
int targetLine = -1;
|
||||||
|
|
||||||
|
if (mode == EditMode::Replace && !searchText.isEmpty()) {
|
||||||
|
int bestMatch = -1;
|
||||||
|
int maxScore = -1;
|
||||||
|
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
if (lines[i].contains(searchText)) {
|
||||||
|
int score = 0;
|
||||||
|
for (int offset = 1; offset <= contextLines; ++offset) {
|
||||||
|
if (i - offset >= 0) score++;
|
||||||
|
if (i + offset < lines.size()) score++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > maxScore) {
|
||||||
|
maxScore = score;
|
||||||
|
bestMatch = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetLine = bestMatch;
|
||||||
|
} else if (mode == EditMode::InsertBefore || mode == EditMode::InsertAfter) {
|
||||||
|
if (lineNumber > 0 && lineNumber <= lines.size()) {
|
||||||
|
targetLine = lineNumber - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLine == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int startBefore = qMax(0, targetLine - contextLines);
|
||||||
|
int countBefore = targetLine - startBefore;
|
||||||
|
contextBefore = lines.mid(startBefore, countBefore).join('\n');
|
||||||
|
|
||||||
|
int startAfter = targetLine + 1;
|
||||||
|
int countAfter = qMin(contextLines, lines.size() - startAfter);
|
||||||
|
contextAfter = lines.mid(startAfter, countAfter).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Tools
|
||||||
|
|
||||||
59
tools/EditProjectFileTool.hpp
Normal file
59
tools/EditProjectFileTool.hpp
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* 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 <llmcore/BaseTool.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Tools {
|
||||||
|
|
||||||
|
class EditProjectFileTool : public LLMCore::BaseTool
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit EditProjectFileTool(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:
|
||||||
|
enum class EditMode { Replace, InsertBefore, InsertAfter, AppendToEnd };
|
||||||
|
|
||||||
|
QString findFileInProject(const QString &fileName) const;
|
||||||
|
QString readFileContent(const QString &filePath) const;
|
||||||
|
void extractContext(
|
||||||
|
const QString &content,
|
||||||
|
EditMode mode,
|
||||||
|
const QString &searchText,
|
||||||
|
int lineNumber,
|
||||||
|
QString &contextBefore,
|
||||||
|
QString &contextAfter,
|
||||||
|
int contextLines = 3) const;
|
||||||
|
|
||||||
|
Context::IgnoreManager *m_ignoreManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Tools
|
||||||
|
|
||||||
@ -24,6 +24,7 @@
|
|||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
#include "EditProjectFileTool.hpp"
|
||||||
#include "GetIssuesListTool.hpp"
|
#include "GetIssuesListTool.hpp"
|
||||||
#include "ListProjectFilesTool.hpp"
|
#include "ListProjectFilesTool.hpp"
|
||||||
#include "ReadProjectFileByNameTool.hpp"
|
#include "ReadProjectFileByNameTool.hpp"
|
||||||
@ -45,6 +46,7 @@ void ToolsFactory::registerTools()
|
|||||||
registerTool(new ListProjectFilesTool(this));
|
registerTool(new ListProjectFilesTool(this));
|
||||||
registerTool(new SearchInProjectTool(this));
|
registerTool(new SearchInProjectTool(this));
|
||||||
registerTool(new GetIssuesListTool(this));
|
registerTool(new GetIssuesListTool(this));
|
||||||
|
registerTool(new EditProjectFileTool(this));
|
||||||
|
|
||||||
LOG_MESSAGE(QString("Registered %1 tools").arg(m_tools.size()));
|
LOG_MESSAGE(QString("Registered %1 tools").arg(m_tools.size()));
|
||||||
}
|
}
|
||||||
@ -93,11 +95,11 @@ QJsonArray ToolsFactory::getToolsDefinitions(LLMCore::ToolSchemaFormat format) c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (requiredPerms.testFlag(LLMCore::ToolPermission::FileSystemWrite)) {
|
if (requiredPerms.testFlag(LLMCore::ToolPermission::FileSystemWrite)) {
|
||||||
// if (!settings.allowFileSystemWrite()) {
|
if (!settings.allowFileSystemWrite()) {
|
||||||
// hasPermission = false;
|
hasPermission = false;
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (requiredPerms.testFlag(LLMCore::ToolPermission::NetworkAccess)) {
|
// if (requiredPerms.testFlag(LLMCore::ToolPermission::NetworkAccess)) {
|
||||||
// if (!settings.allowNetworkAccess()) {
|
// if (!settings.allowNetworkAccess()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user