/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see .
*/
#include "FileEditItem.hpp"
#include "Logger.hpp"
#include "settings/GeneralSettings.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
namespace QodeAssist::Chat {
QMutex FileEditItem::s_fileLockMutex;
QSet 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 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 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