mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2025-11-13 05:22:49 -05:00
revert: Remove edit file tool (#245)
This commit is contained in:
@ -122,7 +122,7 @@ add_qtc_plugin(QodeAssist
|
||||
tools/ToolsManager.hpp tools/ToolsManager.cpp
|
||||
tools/SearchInProjectTool.hpp tools/SearchInProjectTool.cpp
|
||||
tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp
|
||||
tools/EditFileTool.hpp tools/EditFileTool.cpp
|
||||
|
||||
tools/FindSymbolTool.hpp tools/FindSymbolTool.cpp
|
||||
tools/FindFileTool.hpp tools/FindFileTool.cpp
|
||||
tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp
|
||||
|
||||
@ -18,7 +18,6 @@ qt_add_qml_module(QodeAssistChatView
|
||||
qml/parts/AttachedFilesPlace.qml
|
||||
qml/parts/ErrorToast.qml
|
||||
qml/ToolStatusItem.qml
|
||||
qml/FileEditChangesItem.qml
|
||||
qml/parts/RulesViewer.qml
|
||||
|
||||
RESOURCES
|
||||
@ -48,7 +47,7 @@ qt_add_qml_module(QodeAssistChatView
|
||||
ChatSerializer.hpp ChatSerializer.cpp
|
||||
ChatView.hpp ChatView.cpp
|
||||
ChatData.hpp
|
||||
FileEditItem.hpp FileEditItem.cpp
|
||||
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistChatView
|
||||
|
||||
@ -92,8 +92,6 @@ void ClientInterface::sendMessage(
|
||||
"**Workflow patterns:**\n"
|
||||
"- Code structure: find_cpp_symbol → read_files_by_path\n"
|
||||
"- Find usages: find_cpp_symbol → search_in_project\n"
|
||||
"- Fix errors: get_issues_list → find_cpp_symbol → read_files_by_path → edit_file\n"
|
||||
"- Refactoring: find_cpp_symbol → read_files → search_in_project → edit_file\n\n"
|
||||
"**Best practices:**\n"
|
||||
"- Prefer find_cpp_symbol over search_in_project for code symbols\n"
|
||||
"- Read once, edit comprehensively (atomic edits)\n"
|
||||
|
||||
@ -1,380 +0,0 @@
|
||||
/*
|
||||
* 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 "settings/GeneralSettings.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#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) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc.isObject()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonObject editData = doc.object();
|
||||
|
||||
m_editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch());
|
||||
m_mode = editData["mode"].toString();
|
||||
m_filePath = editData["filepath"].toString();
|
||||
m_newText = editData["new_text"].toString();
|
||||
m_searchText = editData["search_text"].toString();
|
||||
m_lineBefore = editData["line_before"].toString();
|
||||
m_lineAfter = editData["line_after"].toString();
|
||||
|
||||
if (m_mode.isEmpty()) {
|
||||
m_mode = m_searchText.isEmpty() ? "insert_after" : "replace";
|
||||
}
|
||||
|
||||
m_addedLines = m_newText.split('\n').size();
|
||||
m_removedLines = m_searchText.isEmpty() ? 0 : m_searchText.split('\n').size();
|
||||
|
||||
emit editIdChanged();
|
||||
emit modeChanged();
|
||||
emit filePathChanged();
|
||||
emit searchTextChanged();
|
||||
emit newTextChanged();
|
||||
emit lineBeforeChanged();
|
||||
emit lineAfterChanged();
|
||||
emit addedLinesChanged();
|
||||
emit removedLinesChanged();
|
||||
|
||||
bool autoApplyEnabled = Settings::generalSettings().autoApplyFileEdits.value();
|
||||
if (autoApplyEnabled) {
|
||||
applyEditInternal(true);
|
||||
}
|
||||
}
|
||||
|
||||
void FileEditItem::applyEdit()
|
||||
{
|
||||
applyEditInternal(false, 0);
|
||||
}
|
||||
|
||||
void FileEditItem::applyEditInternal(bool isAutomatic, int retryCount)
|
||||
{
|
||||
if (isAutomatic) {
|
||||
if (m_status != EditStatus::Pending) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (m_status != EditStatus::Pending && 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()
|
||||
{
|
||||
QString currentContent = readFile(m_filePath);
|
||||
m_originalContent = currentContent;
|
||||
|
||||
QString editedContent;
|
||||
|
||||
if (m_mode == "insert_after") {
|
||||
if (m_lineBefore.isEmpty()) {
|
||||
editedContent = m_newText + currentContent;
|
||||
} else {
|
||||
QList<int> positions;
|
||||
int pos = 0;
|
||||
while ((pos = currentContent.indexOf(m_lineBefore, pos)) != -1) {
|
||||
positions.append(pos);
|
||||
pos += m_lineBefore.length();
|
||||
}
|
||||
|
||||
if (positions.isEmpty()) {
|
||||
rejectWithError("Failed to apply edit: line_before not found");
|
||||
return;
|
||||
}
|
||||
|
||||
int matchedPosition = -1;
|
||||
|
||||
if (!m_lineAfter.isEmpty()) {
|
||||
for (int beforePos : positions) {
|
||||
int afterPos = beforePos + m_lineBefore.length();
|
||||
if (afterPos + m_lineAfter.length() <= currentContent.length()) {
|
||||
QString actualAfter = currentContent.mid(afterPos, m_lineAfter.length());
|
||||
if (actualAfter == m_lineAfter) {
|
||||
matchedPosition = afterPos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPosition == -1) {
|
||||
rejectWithError("Failed to apply edit: line_before found but line_after context doesn't match");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
matchedPosition = positions.first() + m_lineBefore.length();
|
||||
}
|
||||
|
||||
editedContent = currentContent;
|
||||
editedContent.insert(matchedPosition, m_newText);
|
||||
}
|
||||
|
||||
} else if (m_mode == "replace") {
|
||||
if (m_searchText.isEmpty()) {
|
||||
rejectWithError("REPLACE mode requires search_text to be specified");
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasLineBefore = !m_lineBefore.isEmpty();
|
||||
bool hasLineAfter = !m_lineAfter.isEmpty();
|
||||
|
||||
QList<int> searchPositions;
|
||||
int pos = 0;
|
||||
while ((pos = currentContent.indexOf(m_searchText, pos)) != -1) {
|
||||
searchPositions.append(pos);
|
||||
pos += m_searchText.length();
|
||||
}
|
||||
|
||||
if (searchPositions.isEmpty()) {
|
||||
rejectWithError("Failed to apply edit: search_text not found. File may have been modified.");
|
||||
return;
|
||||
}
|
||||
|
||||
int matchedPosition = -1;
|
||||
const int MAX_CONTEXT_DISTANCE = 500;
|
||||
|
||||
for (int searchPos : searchPositions) {
|
||||
bool beforeMatches = true;
|
||||
bool afterMatches = true;
|
||||
|
||||
if (hasLineBefore) {
|
||||
int searchStart = qMax(0, searchPos - MAX_CONTEXT_DISTANCE);
|
||||
int beforePos = currentContent.lastIndexOf(m_lineBefore, searchPos - 1);
|
||||
|
||||
if (beforePos >= searchStart && beforePos < searchPos) {
|
||||
beforeMatches = true;
|
||||
} else {
|
||||
beforeMatches = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLineAfter) {
|
||||
int afterPos = searchPos + m_searchText.length();
|
||||
int searchEnd = qMin(currentContent.length(), afterPos + MAX_CONTEXT_DISTANCE);
|
||||
int foundAfterPos = currentContent.indexOf(m_lineAfter, afterPos);
|
||||
|
||||
if (foundAfterPos >= afterPos && foundAfterPos < searchEnd) {
|
||||
afterMatches = true;
|
||||
} else {
|
||||
afterMatches = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool isMatch = false;
|
||||
|
||||
if (hasLineBefore && hasLineAfter) {
|
||||
isMatch = beforeMatches && afterMatches;
|
||||
} else if (hasLineBefore && !hasLineAfter) {
|
||||
isMatch = beforeMatches;
|
||||
} else if (!hasLineBefore && hasLineAfter) {
|
||||
isMatch = afterMatches;
|
||||
} else {
|
||||
isMatch = true;
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
matchedPosition = searchPos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPosition == -1) {
|
||||
rejectWithError("Failed to apply edit: search_text found but context verification failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
editedContent = currentContent;
|
||||
editedContent.replace(matchedPosition, m_searchText.length(), m_newText);
|
||||
|
||||
} else {
|
||||
rejectWithError(QString("Unknown edit mode: %1").arg(m_mode));
|
||||
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()
|
||||
{
|
||||
if (!writeFile(m_filePath, m_originalContent)) {
|
||||
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)
|
||||
{
|
||||
setStatus(EditStatus::Rejected);
|
||||
setStatusMessage(errorMessage);
|
||||
}
|
||||
|
||||
void FileEditItem::finishWithSuccess(EditStatus status, const QString &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)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QTextStream stream(&file);
|
||||
stream.setAutoDetectUnicode(true);
|
||||
stream << content;
|
||||
file.close();
|
||||
|
||||
if (stream.status() != QTextStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
QString FileEditItem::readFile(const QString &filePath)
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QTextStream stream(&file);
|
||||
stream.setAutoDetectUnicode(true);
|
||||
QString content = stream.readAll();
|
||||
file.close();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
bool FileEditItem::acquireFileLock(const QString &filePath)
|
||||
{
|
||||
QMutexLocker locker(&s_fileLockMutex);
|
||||
|
||||
if (s_lockedFiles.contains(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
s_lockedFiles.insert(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
void FileEditItem::releaseFileLock(const QString &filePath)
|
||||
{
|
||||
QMutexLocker locker(&s_fileLockMutex);
|
||||
s_lockedFiles.remove(filePath);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@ -1,125 +0,0 @@
|
||||
/*
|
||||
* 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 mode READ mode NOTIFY modeChanged FINAL)
|
||||
Q_PROPERTY(QString filePath READ filePath NOTIFY filePathChanged FINAL)
|
||||
Q_PROPERTY(QString searchText READ searchText NOTIFY searchTextChanged FINAL)
|
||||
Q_PROPERTY(QString newText READ newText NOTIFY newTextChanged FINAL)
|
||||
Q_PROPERTY(QString lineBefore READ lineBefore NOTIFY lineBeforeChanged FINAL)
|
||||
Q_PROPERTY(QString lineAfter READ lineAfter NOTIFY lineAfterChanged 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 mode() const { return m_mode; }
|
||||
QString filePath() const { return m_filePath; }
|
||||
QString searchText() const { return m_searchText; }
|
||||
QString newText() const { return m_newText; }
|
||||
QString lineBefore() const { return m_lineBefore; }
|
||||
QString lineAfter() const { return m_lineAfter; }
|
||||
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 modeChanged();
|
||||
void filePathChanged();
|
||||
void searchTextChanged();
|
||||
void newTextChanged();
|
||||
void lineBeforeChanged();
|
||||
void lineAfterChanged();
|
||||
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);
|
||||
|
||||
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_mode;
|
||||
QString m_filePath;
|
||||
QString m_searchText;
|
||||
QString m_newText;
|
||||
QString m_lineBefore;
|
||||
QString m_lineAfter;
|
||||
QString m_originalContent; // Stored when applying edit
|
||||
int m_addedLines = 0;
|
||||
int m_removedLines = 0;
|
||||
EditStatus m_status = EditStatus::Pending;
|
||||
QString m_statusMessage;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@ -1,290 +0,0 @@
|
||||
/*
|
||||
* 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 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
|
||||
|
||||
property bool expanded: false
|
||||
|
||||
anchors.fill: parent
|
||||
implicitHeight: expanded ? headerArea.height + contentColumn.height + root.contentBottomPadding
|
||||
: 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
|
||||
|
||||
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: {
|
||||
var modeText = root.searchText.length > 0 ? qsTr("Replace") : qsTr("Append")
|
||||
return qsTr("%1: %2 (+%3 -%4)")
|
||||
.arg(modeText)
|
||||
.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.isPending || 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
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: oldContentText.implicitHeight + 8
|
||||
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.searchText.length > 0
|
||||
|
||||
TextEdit {
|
||||
id: oldContentText
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
text: root.searchText
|
||||
font.family: root.codeFontFamily
|
||||
font.pixelSize: root.codeFontSize
|
||||
color: Qt.rgba(1, 0.2, 0.2, 0.9)
|
||||
wrapMode: TextEdit.Wrap
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
selectByKeyboard: true
|
||||
textFormat: TextEdit.PlainText
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: newContentText.implicitHeight + 8
|
||||
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)
|
||||
|
||||
TextEdit {
|
||||
id: newContentText
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
text: root.newText
|
||||
font.family: root.codeFontFamily
|
||||
font.pixelSize: root.codeFontSize
|
||||
color: Qt.rgba(0.2, 0.8, 0.2, 0.9)
|
||||
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: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,262 +0,0 @@
|
||||
/*
|
||||
* 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/ProjectUtils.hpp>
|
||||
#include <coreplugin/documentmanager.h>
|
||||
#include <logger/Logger.hpp>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
#include <settings/GeneralSettings.hpp>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRandomGenerator>
|
||||
#include <QTextStream>
|
||||
#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 project files with two modes: REPLACE (find and replace text) or INSERT_AFTER "
|
||||
"(insert after specific line). All text parameters must be complete lines with trailing "
|
||||
"newlines (\\n auto-added if missing).\n"
|
||||
"\n"
|
||||
"REPLACE MODE:\n"
|
||||
"- Finds search_text and replaces with new_text\n"
|
||||
"- Context verification: line_before/line_after searched NEAR search_text (~500 chars), "
|
||||
"not necessarily adjacent\n"
|
||||
"- Both context lines: most precise matching\n"
|
||||
"- One context line: directional search (before/after)\n"
|
||||
"- No context: first occurrence\n"
|
||||
"\n"
|
||||
"INSERT_AFTER MODE:\n"
|
||||
"- Inserts new_text RIGHT AFTER line_before\n"
|
||||
"- Empty line_before: inserts at file start (useful for empty files)\n"
|
||||
"- line_after: must IMMEDIATELY follow line_before for verification\n"
|
||||
"- search_text is ignored\n"
|
||||
"\n"
|
||||
"BEST PRACTICES:\n"
|
||||
"- Sequential additions: use INSERT_AFTER with previous addition as line_before\n"
|
||||
"- Provide stable context lines that won't change\n"
|
||||
"- Make atomic edits (one comprehensive change vs multiple small ones)";
|
||||
}
|
||||
|
||||
QJsonObject EditFileTool::getDefinition(LLMCore::ToolSchemaFormat format) const
|
||||
{
|
||||
QJsonObject properties;
|
||||
|
||||
QJsonObject modeProperty;
|
||||
modeProperty["type"] = "string";
|
||||
modeProperty["enum"] = QJsonArray({"replace", "insert_after"});
|
||||
modeProperty["description"] = "Edit mode: 'replace' (replace search_text with new_text), "
|
||||
"'insert_after' (insert new_text after line_before)";
|
||||
properties["mode"] = modeProperty;
|
||||
|
||||
QJsonObject filepathProperty;
|
||||
filepathProperty["type"] = "string";
|
||||
filepathProperty["description"] = "The absolute or relative file path to edit";
|
||||
properties["filepath"] = filepathProperty;
|
||||
|
||||
QJsonObject newTextProperty;
|
||||
newTextProperty["type"] = "string";
|
||||
newTextProperty["description"] = "Complete line(s) to insert/replace/append. Trailing newline (\\n) auto-added if missing. "
|
||||
"Example: 'int main(int argc, char *argv[]) {\\n' or 'void foo();\\nvoid bar();\\n'";
|
||||
properties["new_text"] = newTextProperty;
|
||||
|
||||
QJsonObject searchTextProperty;
|
||||
searchTextProperty["type"] = "string";
|
||||
searchTextProperty["description"]
|
||||
= "Complete line(s) to search for and replace. Trailing newline (\\n) auto-added if missing. "
|
||||
"REQUIRED for 'replace' mode, IGNORED for other modes. "
|
||||
"Example: 'int main() {\\n' or 'void foo();\\n'";
|
||||
properties["search_text"] = searchTextProperty;
|
||||
|
||||
QJsonObject lineBeforeProperty;
|
||||
lineBeforeProperty["type"] = "string";
|
||||
lineBeforeProperty["description"] = "Complete line for context verification. Trailing newline (\\n) auto-added if missing. "
|
||||
"Usage depends on mode:\n"
|
||||
"- 'replace': OPTIONAL, searched BEFORE search_text (within ~500 chars, not necessarily adjacent)\n"
|
||||
"- 'insert_after': OPTIONAL, new_text inserted RIGHT AFTER this line. "
|
||||
"If empty, inserts at the beginning of the file (useful for empty files)\n"
|
||||
"Example: 'class Movie {\\n' or '#include <iostream>\\n'";
|
||||
properties["line_before"] = lineBeforeProperty;
|
||||
|
||||
QJsonObject lineAfterProperty;
|
||||
lineAfterProperty["type"] = "string";
|
||||
lineAfterProperty["description"] = "Complete line for context verification. Trailing newline (\\n) auto-added if missing. "
|
||||
"Usage depends on mode:\n"
|
||||
"- 'replace': OPTIONAL, searched AFTER search_text (within ~500 chars, not necessarily adjacent)\n"
|
||||
"- 'insert_after': OPTIONAL, must IMMEDIATELY follow line_before for verification\n"
|
||||
"Example: '}\\n' or 'public:\\n'";
|
||||
properties["line_after"] = lineAfterProperty;
|
||||
|
||||
QJsonObject definition;
|
||||
definition["type"] = "object";
|
||||
definition["properties"] = properties;
|
||||
|
||||
QJsonArray required;
|
||||
required.append("mode");
|
||||
required.append("filepath");
|
||||
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 EditFileTool::requiredPermissions() const
|
||||
{
|
||||
return LLMCore::ToolPermissions(LLMCore::ToolPermission::FileSystemRead)
|
||||
| LLMCore::ToolPermissions(LLMCore::ToolPermission::FileSystemWrite);
|
||||
}
|
||||
|
||||
QFuture<QString> EditFileTool::executeAsync(const QJsonObject &input)
|
||||
{
|
||||
return QtConcurrent::run([this, input]() -> QString {
|
||||
QString mode = input["mode"].toString();
|
||||
if (mode.isEmpty()) {
|
||||
throw ToolInvalidArgument("Error: mode parameter is required. Must be one of: 'replace', 'insert_after'");
|
||||
}
|
||||
|
||||
if (mode != "replace" && mode != "insert_after") {
|
||||
throw ToolInvalidArgument(QString("Error: invalid mode '%1'. Must be one of: 'replace', 'insert_after'").arg(mode));
|
||||
}
|
||||
|
||||
QString inputFilepath = input["filepath"].toString();
|
||||
if (inputFilepath.isEmpty()) {
|
||||
throw ToolInvalidArgument("Error: filepath parameter is required");
|
||||
}
|
||||
|
||||
QString newText = input["new_text"].toString();
|
||||
if (newText.isEmpty()) {
|
||||
throw ToolInvalidArgument("Error: new_text parameter is required");
|
||||
}
|
||||
|
||||
QString searchText = input["search_text"].toString();
|
||||
QString lineBefore = input["line_before"].toString();
|
||||
QString lineAfter = input["line_after"].toString();
|
||||
|
||||
if (mode == "replace" && searchText.isEmpty()) {
|
||||
throw ToolInvalidArgument("Error: search_text is required for 'replace' mode");
|
||||
}
|
||||
|
||||
// Normalize text fields: ensure trailing newline if not empty
|
||||
// This handles cases where LLM forgets to add \n
|
||||
auto normalizeText = [](QString &text) {
|
||||
if (!text.isEmpty() && !text.endsWith('\n')) {
|
||||
LOG_MESSAGE(QString("EditFileTool: normalizing text, adding trailing newline (length: %1)").arg(text.length()));
|
||||
text += '\n';
|
||||
}
|
||||
};
|
||||
|
||||
normalizeText(newText);
|
||||
if (!searchText.isEmpty()) normalizeText(searchText);
|
||||
if (!lineBefore.isEmpty()) normalizeText(lineBefore);
|
||||
if (!lineAfter.isEmpty()) normalizeText(lineAfter);
|
||||
|
||||
QString filePath;
|
||||
QFileInfo fileInfo(inputFilepath);
|
||||
|
||||
if (fileInfo.isAbsolute()) {
|
||||
filePath = inputFilepath;
|
||||
} else {
|
||||
auto projects = ProjectExplorer::ProjectManager::projects();
|
||||
if (!projects.isEmpty() && projects.first()) {
|
||||
QString projectDir = projects.first()->projectDirectory().toUrlishString();
|
||||
filePath = QDir(projectDir).absoluteFilePath(inputFilepath);
|
||||
} else {
|
||||
filePath = QFileInfo(inputFilepath).absoluteFilePath();
|
||||
}
|
||||
}
|
||||
|
||||
if (!QFileInfo::exists(filePath)) {
|
||||
throw ToolRuntimeError(QString("Error: File '%1' does not exist").arg(filePath));
|
||||
}
|
||||
|
||||
bool isInProject = Context::ProjectUtils::isFileInProject(filePath);
|
||||
|
||||
if (!isInProject) {
|
||||
const auto &settings = Settings::generalSettings();
|
||||
if (!settings.allowAccessOutsideProject()) {
|
||||
throw ToolRuntimeError(
|
||||
QString("Error: File '%1' is outside the project scope. "
|
||||
"Enable 'Allow file access outside project' in settings to edit files outside project scope.")
|
||||
.arg(filePath));
|
||||
}
|
||||
LOG_MESSAGE(QString("Editing file outside project scope: %1").arg(filePath));
|
||||
}
|
||||
|
||||
auto project = isInProject ? ProjectExplorer::ProjectManager::projectForFile(
|
||||
Utils::FilePath::fromString(filePath)) : nullptr;
|
||||
|
||||
if (project && m_ignoreManager->shouldIgnore(filePath, project)) {
|
||||
throw ToolRuntimeError(
|
||||
QString("Error: File '%1' is excluded by .qodeassistignore").arg(inputFilepath));
|
||||
}
|
||||
|
||||
QJsonObject result;
|
||||
result["type"] = "file_edit";
|
||||
result["mode"] = mode;
|
||||
result["filepath"] = filePath;
|
||||
result["new_text"] = newText;
|
||||
result["search_text"] = searchText;
|
||||
result["line_before"] = lineBefore;
|
||||
result["line_after"] = lineAfter;
|
||||
|
||||
QJsonDocument doc(result);
|
||||
return QString("QODEASSIST_FILE_EDIT:%1")
|
||||
.arg(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)));
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Tools
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
/*
|
||||
* 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 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
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
#include <QJsonObject>
|
||||
|
||||
#include "CreateNewFileTool.hpp"
|
||||
#include "EditFileTool.hpp"
|
||||
#include "FindFileTool.hpp"
|
||||
#include "FindSymbolTool.hpp"
|
||||
#include "GetIssuesListTool.hpp"
|
||||
@ -49,7 +48,6 @@ void ToolsFactory::registerTools()
|
||||
registerTool(new ListProjectFilesTool(this));
|
||||
registerTool(new SearchInProjectTool(this));
|
||||
registerTool(new GetIssuesListTool(this));
|
||||
registerTool(new EditFileTool(this));
|
||||
registerTool(new FindSymbolTool(this));
|
||||
registerTool(new FindFileTool(this));
|
||||
registerTool(new CreateNewFileTool(this));
|
||||
|
||||
Reference in New Issue
Block a user