refactor: Simplified edit tool (#242)

refactor: Re-work edit file tool
This commit is contained in:
Petr Mironychev
2025-10-26 11:47:16 +01:00
committed by GitHub
parent 608103b92e
commit 43b64b9166
29 changed files with 739 additions and 817 deletions

View File

@ -19,6 +19,7 @@
#include "ChatModel.hpp"
#include <utils/aspects.h>
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtQml>
@ -324,7 +325,9 @@ void ChatModel::updateToolResult(
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();
// Generate unique edit ID based on timestamp
QString editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch());
LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId));
@ -332,7 +335,7 @@ void ChatModel::updateToolResult(
Message fileEditMsg;
fileEditMsg.role = ChatRole::FileEdit;
fileEditMsg.content = result;
fileEditMsg.id = editId.isEmpty() ? QString("edit_%1").arg(requestId) : editId;
fileEditMsg.id = editId;
m_messages.append(fileEditMsg);
endInsertRows();

View File

@ -22,6 +22,7 @@
#include "Logger.hpp"
#include "settings/GeneralSettings.hpp"
#include <QDateTime>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
@ -45,7 +46,6 @@ void FileEditItem::parseFromContent(const QString &content)
int markerPos = content.indexOf(marker);
if (markerPos == -1) {
LOG_MESSAGE(QString("FileEditItem: ERROR - no marker found"));
return;
}
@ -56,42 +56,37 @@ void FileEditItem::parseFromContent(const QString &content)
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_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_lineNumber = editData["line_number"].toInt(-1);
m_lineBefore = editData["line_before"].toString();
m_lineAfter = editData["line_after"].toString();
m_addedLines = m_newContent.split('\n').size();
m_removedLines = m_originalContent.split('\n').size();
if (m_mode.isEmpty()) {
m_mode = m_searchText.isEmpty() ? "insert_after" : "replace";
}
LOG_MESSAGE(QString("FileEditItem: parsed successfully, editId=%1, filePath=%2")
.arg(m_editId, m_filePath));
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 editModeChanged();
emit originalContentChanged();
emit newContentChanged();
emit contextBeforeChanged();
emit contextAfterChanged();
emit searchTextChanged();
emit newTextChanged();
emit lineBeforeChanged();
emit lineAfterChanged();
emit addedLinesChanged();
emit removedLinesChanged();
@ -153,18 +148,132 @@ void FileEditItem::revertEdit()
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;
}
m_originalContent = currentContent;
bool success = false;
QString editedContent = applyEditToContent(currentContent, success);
if (!success) {
rejectWithError("Failed to apply edit: could not find context. File may have been modified.");
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;
}
@ -178,22 +287,7 @@ void FileEditItem::performApply()
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)) {
if (!writeFile(m_filePath, m_originalContent)) {
rejectWithError(QString("Failed to write reverted file: %1").arg(m_filePath));
return;
}
@ -203,14 +297,12 @@ void FileEditItem::performRevert()
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);
}
@ -237,7 +329,6 @@ 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;
}
@ -247,7 +338,6 @@ bool FileEditItem::writeFile(const QString &filePath, const QString &content)
file.close();
if (stream.status() != QTextStream::Ok) {
LOG_MESSAGE(QString("Error writing to file: %1").arg(filePath));
return false;
}
@ -258,7 +348,6 @@ 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();
}
@ -270,184 +359,6 @@ QString FileEditItem::readFile(const QString &filePath)
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);
@ -457,17 +368,13 @@ bool FileEditItem::acquireFileLock(const QString &filePath)
}
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));
}
s_lockedFiles.remove(filePath);
}
} // namespace QodeAssist::Chat

View File

@ -47,12 +47,12 @@ public:
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 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(QString contextBefore READ contextBefore NOTIFY contextBeforeChanged FINAL)
Q_PROPERTY(QString contextAfter READ contextAfter NOTIFY contextAfterChanged 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)
@ -62,12 +62,12 @@ 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 editMode() const { return m_editMode; }
QString originalContent() const { return m_originalContent; }
QString newContent() const { return m_newContent; }
QString contextBefore() const { return m_contextBefore; }
QString contextAfter() const { return m_contextAfter; }
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; }
@ -79,12 +79,12 @@ public:
signals:
void editIdChanged();
void modeChanged();
void filePathChanged();
void editModeChanged();
void originalContentChanged();
void newContentChanged();
void contextBeforeChanged();
void contextAfterChanged();
void searchTextChanged();
void newTextChanged();
void lineBeforeChanged();
void lineAfterChanged();
void addedLinesChanged();
void removedLinesChanged();
void statusChanged();
@ -101,8 +101,6 @@ private:
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);
@ -110,14 +108,13 @@ private:
static QSet<QString> s_lockedFiles;
QString m_editId;
QString m_mode;
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;
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;

View File

@ -153,14 +153,7 @@ FileEditItem {
id: headerText
Layout.fillWidth: true
text: {
var modeText = ""
switch(root.editMode) {
case "replace": modeText = qsTr("Replace"); break;
case "insert_before": modeText = qsTr("Insert Before"); break;
case "insert_after": modeText = qsTr("Insert After"); break;
case "append": modeText = qsTr("Append"); break;
default: modeText = root.editMode;
}
var modeText = root.searchText.length > 0 ? qsTr("Replace") : qsTr("Append")
return qsTr("%1: %2 (+%3 -%4)")
.arg(modeText)
.arg(root.filePath)
@ -224,21 +217,6 @@ FileEditItem {
spacing: 4
visible: opacity > 0
TextEdit {
Layout.fillWidth: true
visible: root.contextBefore.length > 0
text: root.contextBefore
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: palette.mid
wrapMode: TextEdit.Wrap
opacity: 0.6
readOnly: true
selectByMouse: true
selectByKeyboard: true
textFormat: TextEdit.PlainText
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: oldContentText.implicitHeight + 8
@ -246,7 +224,7 @@ FileEditItem {
radius: 4
border.width: 1
border.color: Qt.rgba(1, 0.2, 0.2, 0.3)
visible: root.originalContent.length > 0
visible: root.searchText.length > 0
TextEdit {
id: oldContentText
@ -256,7 +234,7 @@ FileEditItem {
top: parent.top
margins: 4
}
text: "- " + root.originalContent
text: root.searchText
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: Qt.rgba(1, 0.2, 0.2, 0.9)
@ -284,7 +262,7 @@ FileEditItem {
top: parent.top
margins: 4
}
text: "+ " + root.newContent
text: root.newText
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: Qt.rgba(0.2, 0.8, 0.2, 0.9)
@ -296,21 +274,6 @@ FileEditItem {
}
}
TextEdit {
Layout.fillWidth: true
visible: root.contextAfter.length > 0
text: root.contextAfter
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: palette.mid
wrapMode: TextEdit.Wrap
opacity: 0.6
readOnly: true
selectByMouse: true
selectByKeyboard: true
textFormat: TextEdit.PlainText
}
Text {
Layout.fillWidth: true
visible: root.statusMessage.length > 0