diff --git a/context/ChangesManager.cpp b/context/ChangesManager.cpp index 33ac7fc..f6bbead 100644 --- a/context/ChangesManager.cpp +++ b/context/ChangesManager.cpp @@ -269,7 +269,7 @@ bool ChangesManager::undoFileEdit(const QString &editId) QString errorMsg; bool isAppend = oldContentCopy.isEmpty(); bool success = performFragmentReplacement( - filePathCopy, newContentCopy, oldContentCopy, isAppend, &errorMsg); + filePathCopy, newContentCopy, oldContentCopy, isAppend, &errorMsg, true); locker.relock(); @@ -365,7 +365,7 @@ bool ChangesManager::performFileEdit( } } else { double similarity = 0.0; - QString matchedContent = findBestMatch(currentContent, oldContent, 0.8, &similarity); + QString matchedContent = findBestMatch(currentContent, oldContent, 0.82, &similarity); if (!matchedContent.isEmpty()) { matchPos = currentContent.indexOf(matchedContent); if (matchPos != -1) { @@ -391,7 +391,7 @@ bool ChangesManager::performFileEdit( LOG_MESSAGE(QString("Old content not found in open editor (best similarity: %1%%): %2") .arg(qRound(similarity * 100)).arg(filePath)); - setError(QString("Content not found. Best match: %1%% (threshold: 80%%). " + setError(QString("Content not found. Best match: %1%% (threshold: 82%%). " "File may have changed.").arg(qRound(similarity * 100))); return false; } @@ -417,19 +417,32 @@ bool ChangesManager::performFileEdit( setError("Applied successfully (appended to end of file)"); } else if (currentContent.contains(oldContent)) { - updatedContent = currentContent.replace(oldContent, newContent); - LOG_MESSAGE(QString("Using exact match for file update: %1").arg(filePath)); + int matchPos = currentContent.indexOf(oldContent); + updatedContent = currentContent.left(matchPos) + + newContent + + currentContent.mid(matchPos + oldContent.length()); + LOG_MESSAGE(QString("Using exact match for file update: %1 at position %2") + .arg(filePath).arg(matchPos)); setError("Applied successfully (exact match)"); } else { double similarity = 0.0; - QString matchedContent = findBestMatch(currentContent, oldContent, 0.8, &similarity); + QString matchedContent = findBestMatch(currentContent, oldContent, 0.82, &similarity); if (!matchedContent.isEmpty()) { - updatedContent = currentContent.replace(matchedContent, newContent); - LOG_MESSAGE(QString("Using fuzzy match (%1%%) for file update: %2") - .arg(qRound(similarity * 100)).arg(filePath)); + int matchPos = currentContent.indexOf(matchedContent); + if (matchPos == -1) { + QString msg = "Internal error: matched content not found in file"; + LOG_MESSAGE(QString("Internal error: matched content disappeared: %1").arg(filePath)); + setError(msg); + return false; + } + updatedContent = currentContent.left(matchPos) + + newContent + + currentContent.mid(matchPos + matchedContent.length()); + LOG_MESSAGE(QString("Using fuzzy match (%1%%) for file update: %2 at position %3") + .arg(qRound(similarity * 100)).arg(filePath).arg(matchPos)); setError(QString("Applied with fuzzy match (%1%% similarity)").arg(qRound(similarity * 100))); } else { - QString msg = QString("Content not found. Best match: %1%% (threshold: 80%%). " + QString msg = QString("Content not found. Best match: %1%% (threshold: 82%%). " "File may have changed.").arg(qRound(similarity * 100)); LOG_MESSAGE(QString("Old content not found in file (best similarity: %1%%): %2") .arg(qRound(similarity * 100)).arg(filePath)); @@ -458,6 +471,11 @@ int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) co const int len1 = s1.length(); const int len2 = s2.length(); + const int MAX_LENGTH = 10000; + if (len1 > MAX_LENGTH || len2 > MAX_LENGTH) { + return qAbs(len1 - len2) + qMin(len1, len2) / 2; + } + QVector> d(len1 + 1, QVector(len2 + 1)); for (int i = 0; i <= len1; ++i) { @@ -481,6 +499,72 @@ int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) co return d[len1][len2]; } +QString ChangesManager::findBestMatchLineBased( + const QString &fileContent, + const QString &searchContent, + double threshold, + double *outSimilarity) const +{ + QStringList fileLines = fileContent.split('\n'); + QStringList searchLines = searchContent.split('\n'); + + if (searchLines.isEmpty() || fileLines.isEmpty()) { + if (outSimilarity) *outSimilarity = 0.0; + return QString(); + } + + if (searchLines.size() > fileLines.size()) { + if (outSimilarity) *outSimilarity = 0.0; + return QString(); + } + + QString bestMatch; + double bestSimilarity = 0.0; + int searchLineCount = searchLines.size(); + + LOG_MESSAGE(QString("Line-based search: %1 search lines in %2 file lines") + .arg(searchLineCount).arg(fileLines.size())); + + for (int i = 0; i <= fileLines.size() - searchLineCount; ++i) { + int matchingLines = 0; + int totalLines = searchLineCount; + + for (int j = 0; j < searchLineCount; ++j) { + if (fileLines[i + j] == searchLines[j]) { + matchingLines++; + } + } + + double similarity = static_cast(matchingLines) / totalLines; + + if (similarity > bestSimilarity) { + bestSimilarity = similarity; + if (similarity >= threshold) { + QStringList matchedLines; + for (int j = 0; j < searchLineCount; ++j) { + matchedLines.append(fileLines[i + j]); + } + bestMatch = matchedLines.join('\n'); + + if (similarity >= 0.99) { + if (outSimilarity) *outSimilarity = similarity; + LOG_MESSAGE(QString("Found exact line match at line %1").arg(i + 1)); + return bestMatch; + } + } + } + } + + if (outSimilarity) { + *outSimilarity = bestSimilarity; + } + + LOG_MESSAGE(QString("Line-based search complete, best similarity: %1%%") + .arg(qRound(bestSimilarity * 100))); + + return bestMatch; +} + QString ChangesManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const { if (searchContent.isEmpty() || fileContent.isEmpty()) { @@ -496,11 +580,37 @@ QString ChangesManager::findBestMatch(const QString &fileContent, const QString return QString(); } + const int MAX_SEARCH_LENGTH = 50000; + if (searchLen > MAX_SEARCH_LENGTH) { + LOG_MESSAGE(QString("Search content too large (%1 chars), using line-based search").arg(searchLen)); + return findBestMatchLineBased(fileContent, searchContent, threshold, outSimilarity); + } + QString bestMatch; double bestSimilarity = 0.0; - for (int i = 0; i <= fileLen - searchLen; ++i) { + QChar firstChar = searchContent.at(0); + + int step = 1; + if (fileLen > 100000 && searchLen > 1000) { + step = searchLen / 10; + if (step < 1) step = 1; + } + + int searchEnd = fileLen - searchLen + 1; + + for (int i = 0; i < searchEnd; i += step) { + if (step == 1 && fileContent.at(i) != firstChar) { + continue; + } + QString candidate = fileContent.mid(i, searchLen); + + int lengthDiff = qAbs(candidate.length() - searchLen); + if (lengthDiff > searchLen * 0.3) { + continue; + } + int distance = levenshteinDistance(candidate, searchContent); double similarity = 1.0 - (static_cast(distance) / searchLen); @@ -508,8 +618,19 @@ QString ChangesManager::findBestMatch(const QString &fileContent, const QString bestSimilarity = similarity; if (similarity >= threshold) { bestMatch = candidate; + + if (similarity >= 0.95) { + if (outSimilarity) *outSimilarity = bestSimilarity; + LOG_MESSAGE(QString("Found excellent match early (similarity: %1%%), stopping search").arg(qRound(similarity * 100))); + return bestMatch; + } } } + + if (i > searchLen * 3 && bestSimilarity < 0.5) { + LOG_MESSAGE("Early termination: no good matches found in first 3x search area"); + break; + } } if (outSimilarity) { @@ -576,7 +697,8 @@ bool ChangesManager::performFragmentReplacement( const QString &searchContent, const QString &replaceContent, bool isAppendOperation, - QString *errorMsg) + QString *errorMsg, + bool isUndo) { QString currentContent = readFileContent(filePath); if (currentContent.isNull()) { @@ -606,36 +728,120 @@ bool ChangesManager::performFragmentReplacement( } } } else { + double minThreshold = isUndo ? 0.70 : 0.85; + + LOG_MESSAGE(QString("Fragment replacement: isUndo=%1, threshold=%2%%") + .arg(isUndo ? "yes" : "no") + .arg(qRound(minThreshold * 100))); + double similarity = 0.0; QString matchType; QString matchedContent = findBestMatchWithNormalization( currentContent, searchContent, &similarity, &matchType); + if (!matchedContent.isEmpty() && similarity < minThreshold) { + QString msg = QString("Cannot %1: similarity too low (%2%%, threshold: %3%%). %4") + .arg(isUndo ? "undo" : "apply") + .arg(qRound(similarity * 100)) + .arg(qRound(minThreshold * 100)) + .arg(isUndo ? "File may have been modified." + : "LLM may have provided incorrect oldContent."); + if (errorMsg) *errorMsg = msg; + LOG_MESSAGE(QString("Fragment replacement failed: %1").arg(msg)); + return false; + } + if (!matchedContent.isEmpty()) { - resultContent = currentContent; - resultContent.replace(matchedContent, replaceContent); + int matchPos = currentContent.indexOf(matchedContent); + if (matchPos == -1) { + if (errorMsg) { + *errorMsg = "Internal error: matched content not found in file"; + } + LOG_MESSAGE(QString("Internal error: matched content disappeared: %1").arg(filePath)); + return false; + } + + resultContent = currentContent.left(matchPos) + + replaceContent + + currentContent.mid(matchPos + matchedContent.length()); + + LOG_MESSAGE(QString("Replaced content at position %1 (length: %2 -> %3)") + .arg(matchPos) + .arg(matchedContent.length()) + .arg(replaceContent.length())); if (errorMsg) { if (matchType == "exact") { - *errorMsg = "Successfully applied"; + *errorMsg = isUndo ? "Successfully undone" : "Successfully applied"; } else if (matchType.startsWith("fuzzy")) { - *errorMsg = QString("Applied (%1%% similarity)") + *errorMsg = QString("%1 (%2%% similarity)") + .arg(isUndo ? "Undone" : "Applied") .arg(qRound(similarity * 100)); } } } else { if (errorMsg) { - *errorMsg = QString("Cannot apply: similarity too low (%1%%). File may have been modified.") - .arg(qRound(similarity * 100)); + *errorMsg = QString("Cannot %1: content not found in file.") + .arg(isUndo ? "undo" : "apply"); } - LOG_MESSAGE(QString("Failed to find content for fragment replacement: %1 (similarity: %2%%)") - .arg(filePath).arg(qRound(similarity * 100))); + LOG_MESSAGE(QString("Failed to find content for fragment replacement: %1").arg(filePath)); return false; } } - DiffInfo freshDiff = createDiffInfo(currentContent, resultContent, filePath); - return performFileEditWithDiff(filePath, freshDiff, false, errorMsg); + auto editors = Core::EditorManager::visibleEditors(); + for (auto *editor : editors) { + if (!editor || !editor->document()) { + continue; + } + + QString editorPath = editor->document()->filePath().toFSPathString(); + if (editorPath == filePath) { + if (auto *textEditor = qobject_cast(editor->document())) { + QTextDocument *doc = textEditor->document(); + + try { + QTextCursor cursor(doc); + if (!cursor.isNull()) { + cursor.beginEditBlock(); + cursor.select(QTextCursor::Document); + cursor.removeSelectedText(); + cursor.insertText(resultContent); + cursor.endEditBlock(); + + if (errorMsg && errorMsg->isEmpty()) { + *errorMsg = isUndo ? "Successfully undone" : "Successfully applied"; + } + LOG_MESSAGE(QString("Applied fragment replacement to open editor: %1").arg(filePath)); + return true; + } + } catch (...) { + LOG_MESSAGE("Exception during document modification"); + if (errorMsg) *errorMsg = "Exception during document modification"; + return false; + } + } + } + } + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + QString msg = QString("Cannot write file: %1").arg(file.errorString()); + LOG_MESSAGE(QString("Failed to open file for writing: %1 - %2") + .arg(filePath, file.errorString())); + if (errorMsg) *errorMsg = msg; + return false; + } + + QTextStream out(&file); + out << resultContent; + file.close(); + + if (errorMsg && errorMsg->isEmpty()) { + *errorMsg = isUndo ? "Successfully undone" : "Successfully applied"; + } + LOG_MESSAGE(QString("Applied fragment replacement to file: %1").arg(filePath)); + return true; } bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg) @@ -741,7 +947,7 @@ bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *e QString errMsg; bool isAppend = oldContentCopy.isEmpty(); bool success = performFragmentReplacement( - filePathCopy, newContentCopy, oldContentCopy, isAppend, &errMsg); + filePathCopy, newContentCopy, oldContentCopy, isAppend, &errMsg, true); locker.relock(); @@ -919,7 +1125,7 @@ QString ChangesManager::readFileContent(const QString &filePath) const QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { LOG_MESSAGE(QString(" Failed to read file: %1").arg(file.errorString())); - return QString(); // Return null QString on error + return QString(); } QString content = QString::fromUtf8(file.readAll()); @@ -984,15 +1190,11 @@ bool ChangesManager::performFileEditWithDiff( setError("Document pointer is null"); return false; } - - - bool oldBlockState = doc->blockSignals(true); try { QTextCursor cursor(doc); if (cursor.isNull()) { - doc->blockSignals(oldBlockState); LOG_MESSAGE(" Cursor is invalid"); setError("Cannot create text cursor"); return false; @@ -1004,26 +1206,20 @@ bool ChangesManager::performFileEditWithDiff( cursor.insertText(modifiedContent); cursor.endEditBlock(); - doc->blockSignals(oldBlockState); - - emit doc->contentsChange(0, doc->characterCount(), doc->characterCount()); - LOG_MESSAGE(QString(" ✓ Successfully applied diff to open editor: %1").arg(filePath)); setError(diffErrorMsg); return true; } catch (...) { - doc->blockSignals(oldBlockState); LOG_MESSAGE(" Exception during document modification"); setError("Exception during document modification"); return false; } - - doc->blockSignals(false); } } } LOG_MESSAGE(" File not open in editor, modifying file directly..."); + LOG_MESSAGE(" Note: Undo (Ctrl+Z) will not be available for this file until it is opened"); QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { @@ -1382,10 +1578,13 @@ bool ChangesManager::applyDiffToContent( QString searchContent = reverse ? diffInfo.modifiedContent : diffInfo.originalContent; QString replaceContent = reverse ? diffInfo.originalContent : diffInfo.modifiedContent; - if (content.contains(searchContent)) { - content.replace(searchContent, replaceContent); + int matchPos = content.indexOf(searchContent); + if (matchPos != -1) { + content = content.left(matchPos) + + replaceContent + + content.mid(matchPos + searchContent.length()); setError("Applied using fallback mode (direct replacement)"); - LOG_MESSAGE(" ✓ Fallback: Direct replacement successful"); + LOG_MESSAGE(QString(" ✓ Fallback: Direct replacement successful at position %1").arg(matchPos)); return true; } else { setError("Fallback failed: Original content not found in file"); diff --git a/context/ChangesManager.h b/context/ChangesManager.h index bcb3f2f..10a9620 100644 --- a/context/ChangesManager.h +++ b/context/ChangesManager.h @@ -135,10 +135,12 @@ private: const QString &searchContent, const QString &replaceContent, bool isAppendOperation, - QString *errorMsg = nullptr); + QString *errorMsg = nullptr, + bool isUndo = false); int levenshteinDistance(const QString &s1, const QString &s2) const; - QString findBestMatch(const QString &fileContent, const QString &searchContent, double threshold = 0.8, double *outSimilarity = nullptr) const; + QString findBestMatch(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const; + QString findBestMatchLineBased(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const; QString findBestMatchWithNormalization(const QString &fileContent, const QString &searchContent, double *outSimilarity = nullptr, QString *outMatchType = nullptr) const; struct RequestEdits diff --git a/tools/EditFileTool.cpp b/tools/EditFileTool.cpp index 7637212..663eb85 100644 --- a/tools/EditFileTool.cpp +++ b/tools/EditFileTool.cpp @@ -55,7 +55,15 @@ QString EditFileTool::description() const "Provide the filename (or absolute path), old_content to find and replace, " "and new_content to replace it with. Changes are applied immediately if auto-apply " "is enabled in settings. The user can undo or reapply changes at any time. " - "If old_content is empty, new_content will be appended to the end of the file."; + "\n\nIMPORTANT:" + "\n- To insert at the BEGINNING of a file (e.g., copyright header), you MUST provide " + "the EXACT first few lines of the file as old_content (at least 3-5 lines), " + "then put those lines + new header in new_content." + "\n- To append at the END of file, use empty old_content." + "\n- For replacements in the middle, provide EXACT matching text with sufficient " + "context (at least 5-10 lines) to ensure correct placement." + "\n- The system requires 85% similarity for first-time edits. Provide accurate " + "old_content to avoid incorrect placement."; } QJsonObject EditFileTool::getDefinition(LLMCore::ToolSchemaFormat format) const @@ -172,6 +180,22 @@ QFuture EditFileTool::executeAsync(const QJsonObject &input) QString editId = QUuid::createUuid().toString(QUuid::WithoutBraces); bool autoApply = Settings::toolsSettings().autoApplyFileEdits(); + LOG_MESSAGE(QString("EditFileTool: Edit details for %1:").arg(filePath)); + LOG_MESSAGE(QString(" oldContent length: %1 chars").arg(oldContent.length())); + LOG_MESSAGE(QString(" newContent length: %1 chars").arg(newContent.length())); + if (oldContent.length() <= 200) { + LOG_MESSAGE(QString(" oldContent: '%1'").arg(oldContent)); + } else { + LOG_MESSAGE(QString(" oldContent (first 200 chars): '%1...'") + .arg(oldContent.left(200))); + } + if (newContent.length() <= 200) { + LOG_MESSAGE(QString(" newContent: '%1'").arg(newContent)); + } else { + LOG_MESSAGE(QString(" newContent (first 200 chars): '%1...'") + .arg(newContent.left(200))); + } + Context::ChangesManager::instance().addFileEdit( editId, filePath,