-
Notifications
You must be signed in to change notification settings - Fork 34
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: display AI Fix ⚡️ [IDE-580] (#596)
* wip: enable AI Fix * feat: wip - bridge button action with LS wrapper * feat: get Code Diff from LS * debug: test Language Server response * fix: wip parsing of command * feat: display AI suggestions ⚡️ * chore: update `Apply fix` button style * chore: group similar JS logic * fix: responsive UI during AI Fix call with LS async request * feat: wip - apply fix * fix: added support for navigating between AI Fixes * feat: show section for error, if ai fix reqest fails to generate fixes * fix: add the same spacing for error section as for show fixes section * chore: wip - apply fix * chore: wip add test to handle patches * fix: fix flow for regenerating ai fixes after error * tidy: replace hardcoded colors with JBUI theme colors * tidy: remove println's and disable devtools * chore: updating logging and error handling, changing threading to runAsync * fix: solved a problem with applying patch * chore: replace colors with colors from JBUI * tidy: cleanup code, remove temporary logging * fix: update unit test and remove duplicate code * fix: improve error handling and added balloon notifier for failing to apply patch * tidy: refactor functions to a new patcher * tidy: remove unused parameters * fix: updating logging and refactor types * fix: add balloon notifier for file not found --------- Co-authored-by: Catalina Oyaneder <[email protected]> Co-authored-by: Bastian Doetsch <[email protected]> Co-authored-by: Knut Funkel <[email protected]>
- Loading branch information
1 parent
a790ab7
commit 2c09695
Showing
13 changed files
with
664 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
package io.snyk.plugin | ||
|
||
data class DiffPatch( | ||
val originalFile: String, | ||
val fixedFile: String, | ||
val hunks: List<Hunk> | ||
) | ||
|
||
data class Hunk( | ||
val startLineOriginal: Int, | ||
val numLinesOriginal: Int, | ||
val startLineFixed: Int, | ||
val numLinesFixed: Int, | ||
val changes: List<Change> | ||
) | ||
|
||
sealed class Change { | ||
data class Addition(val line: String) : Change() | ||
data class Deletion(val line: String) : Change() | ||
data class Context(val line: String) : Change() // Unchanged line for context | ||
} | ||
|
||
class DiffPatcher { | ||
|
||
fun applyPatch(fileContent: String, diffPatch: DiffPatch): String { | ||
val lines = fileContent.lines().toMutableList() | ||
|
||
for (hunk in diffPatch.hunks) { | ||
var originalLineIndex = hunk.startLineOriginal - 1 // Convert to 0-based index | ||
|
||
for (change in hunk.changes) { | ||
when (change) { | ||
is Change.Addition -> { | ||
lines.add(originalLineIndex, change.line) | ||
originalLineIndex++ | ||
} | ||
|
||
is Change.Deletion -> { | ||
if (originalLineIndex < lines.size && lines[originalLineIndex].trim() == change.line) { | ||
lines.removeAt(originalLineIndex) | ||
} | ||
} | ||
|
||
is Change.Context -> { | ||
originalLineIndex++ // Move past unchanged context lines | ||
} | ||
} | ||
} | ||
} | ||
return lines.joinToString("\n") | ||
} | ||
|
||
fun parseDiff(diff: String): DiffPatch { | ||
val lines = diff.lines() | ||
val originalFile = lines.first { it.startsWith("---") }.substringAfter("--- ") | ||
val fixedFile = lines.first { it.startsWith("+++") }.substringAfter("+++ ") | ||
|
||
val hunks = mutableListOf<Hunk>() | ||
var currentHunk: Hunk? = null | ||
val changes = mutableListOf<Change>() | ||
|
||
for (line in lines) { | ||
when { | ||
line.startsWith("@@") -> { | ||
// Parse hunk header (e.g., @@ -4,9 +4,14 @@) | ||
val hunkHeader = line.substringAfter("@@ ").substringBefore(" @@").split(" ") | ||
val original = hunkHeader[0].substring(1).split(",") | ||
val fixed = hunkHeader[1].substring(1).split(",") | ||
|
||
val startLineOriginal = original[0].toInt() | ||
val numLinesOriginal = original.getOrNull(1)?.toInt() ?: 1 | ||
val startLineFixed = fixed[0].toInt() | ||
val numLinesFixed = fixed.getOrNull(1)?.toInt() ?: 1 | ||
|
||
if (currentHunk != null) { | ||
hunks.add(currentHunk.copy(changes = changes.toList())) | ||
changes.clear() | ||
} | ||
currentHunk = Hunk( | ||
startLineOriginal = startLineOriginal, | ||
numLinesOriginal = numLinesOriginal, | ||
startLineFixed = startLineFixed, | ||
numLinesFixed = numLinesFixed, | ||
changes = emptyList() | ||
) | ||
} | ||
|
||
line.startsWith("---") || line.startsWith("+++") -> { | ||
// Skip file metadata lines (--- and +++) | ||
continue | ||
} | ||
|
||
line.startsWith("-") -> changes.add(Change.Deletion(line.substring(1).trim())) | ||
line.startsWith("+") -> changes.add(Change.Addition(line.substring(1).trim())) | ||
else -> changes.add(Change.Context(line.trim())) | ||
} | ||
} | ||
|
||
// Add the last hunk | ||
if (currentHunk != null) { | ||
hunks.add(currentHunk.copy(changes = changes.toList())) | ||
} | ||
|
||
return DiffPatch( | ||
originalFile = originalFile, | ||
fixedFile = fixedFile, | ||
hunks = hunks | ||
) | ||
} | ||
} |
108 changes: 108 additions & 0 deletions
108
src/main/kotlin/io/snyk/plugin/ui/jcef/ApplyFixHandler.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
package io.snyk.plugin.ui.jcef | ||
|
||
import com.intellij.openapi.command.WriteCommandAction | ||
import com.intellij.openapi.diagnostic.LogLevel | ||
import com.intellij.openapi.diagnostic.Logger | ||
import com.intellij.openapi.fileEditor.FileDocumentManager | ||
import com.intellij.openapi.project.Project | ||
import com.intellij.ui.jcef.JBCefBrowserBase | ||
import com.intellij.ui.jcef.JBCefJSQuery | ||
import io.snyk.plugin.DiffPatcher | ||
import io.snyk.plugin.toVirtualFile | ||
import org.cef.browser.CefBrowser | ||
import org.cef.browser.CefFrame | ||
import org.cef.handler.CefLoadHandlerAdapter | ||
import org.jetbrains.concurrency.runAsync | ||
import io.snyk.plugin.ui.SnykBalloonNotificationHelper | ||
import snyk.common.lsp.LanguageServerWrapper | ||
import java.io.IOException | ||
|
||
class ApplyFixHandler(private val project: Project) { | ||
|
||
val logger = Logger.getInstance(this::class.java).apply { | ||
// tie log level to language server log level | ||
val languageServerWrapper = LanguageServerWrapper.getInstance() | ||
if (languageServerWrapper.logger.isDebugEnabled) this.setLevel(LogLevel.DEBUG) | ||
if (languageServerWrapper.logger.isTraceEnabled) this.setLevel(LogLevel.TRACE) | ||
} | ||
|
||
|
||
fun generateApplyFixCommand(jbCefBrowser: JBCefBrowserBase): CefLoadHandlerAdapter { | ||
val applyFixQuery = JBCefJSQuery.create(jbCefBrowser) | ||
|
||
applyFixQuery.addHandler { value -> | ||
val params = value.split("|@", limit = 2) | ||
val filePath = params[0] // Path to the file that needs to be patched | ||
val patch = params[1] // The patch we received from LS | ||
|
||
// Avoid blocking the UI thread | ||
runAsync { | ||
val result = try { | ||
applyPatchAndSave(project, filePath, patch) | ||
} catch (e: IOException) { // Catch specific file-related exceptions | ||
logger.error("Error applying patch to file: $filePath. e:$e") | ||
Result.failure(e) | ||
} catch (e: Exception) { | ||
logger.error("Unexpected error applying patch. e:$e") | ||
Result.failure(e) | ||
} | ||
|
||
if (result.isSuccess) { | ||
val script = """ | ||
window.receiveApplyFixResponse(true); | ||
""".trimIndent() | ||
jbCefBrowser.cefBrowser.executeJavaScript(script, jbCefBrowser.cefBrowser.url, 0) | ||
} else { | ||
val errorMessage = "Error applying fix: ${result.exceptionOrNull()?.message}" | ||
SnykBalloonNotificationHelper.showError(errorMessage, project) | ||
val errorScript = """ | ||
window.receiveApplyFixResponse(false, "$errorMessage"); | ||
""".trimIndent() | ||
jbCefBrowser.cefBrowser.executeJavaScript(errorScript, jbCefBrowser.cefBrowser.url, 0) | ||
} | ||
|
||
} | ||
return@addHandler JBCefJSQuery.Response("success") | ||
} | ||
|
||
return object : CefLoadHandlerAdapter() { | ||
override fun onLoadEnd(browser: CefBrowser, frame: CefFrame, httpStatusCode: Int) { | ||
if (frame.isMain) { | ||
val script = """ | ||
(function() { | ||
if (window.applyFixQuery) { | ||
return; | ||
} | ||
window.applyFixQuery = function(value) { ${applyFixQuery.inject("value")} }; | ||
})(); | ||
""".trimIndent() | ||
browser.executeJavaScript(script, browser.url, 0) | ||
} | ||
} | ||
} | ||
} | ||
|
||
private fun applyPatchAndSave(project: Project, filePath: String, patch: String): Result<Unit> { | ||
val virtualFile = filePath.toVirtualFile() | ||
val patcher = DiffPatcher() | ||
|
||
WriteCommandAction.runWriteCommandAction(project) { | ||
val document = FileDocumentManager.getInstance().getDocument(virtualFile) | ||
if (document != null) { | ||
val originalContent = document.text | ||
val patchedContent = patcher.applyPatch(originalContent, patcher.parseDiff(patch)) | ||
if (originalContent != patchedContent) { | ||
document.setText(patchedContent) | ||
} else { | ||
logger.warn("[applyPatchAndSave] Patch did not modify content: $filePath") | ||
} | ||
} else { | ||
logger.error("[applyPatchAndSave] Failed to find document for: $filePath") | ||
val errorMessage = "Failed to find document for: $filePath" | ||
SnykBalloonNotificationHelper.showError(errorMessage, project) | ||
return@runWriteCommandAction | ||
} | ||
} | ||
return Result.success(Unit) | ||
} | ||
} |
70 changes: 70 additions & 0 deletions
70
src/main/kotlin/io/snyk/plugin/ui/jcef/GenerateAIFixHandler.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
package io.snyk.plugin.ui.jcef | ||
|
||
import com.google.gson.Gson | ||
import com.intellij.ui.jcef.JBCefBrowserBase | ||
import com.intellij.ui.jcef.JBCefJSQuery | ||
import org.cef.browser.CefBrowser | ||
import org.cef.browser.CefFrame | ||
import org.cef.handler.CefLoadHandlerAdapter | ||
import org.jetbrains.concurrency.runAsync | ||
import snyk.common.lsp.LanguageServerWrapper | ||
import snyk.common.lsp.Fix | ||
|
||
|
||
class GenerateAIFixHandler() { | ||
|
||
fun generateAIFixCommand(jbCefBrowser: JBCefBrowserBase): CefLoadHandlerAdapter { | ||
val aiFixQuery = JBCefJSQuery.create(jbCefBrowser) | ||
|
||
aiFixQuery.addHandler { value -> | ||
val params = value.split(":") | ||
val folderURI = params[0] | ||
val fileURI = params[1] | ||
val issueID = params[2] | ||
|
||
|
||
runAsync { | ||
val responseDiff: List<Fix> = | ||
LanguageServerWrapper.getInstance().sendCodeFixDiffsCommand(folderURI, fileURI, issueID) | ||
|
||
val script = """ | ||
window.receiveAIFixResponse(${Gson().toJson(responseDiff)}); | ||
""".trimIndent() | ||
|
||
jbCefBrowser.cefBrowser.executeJavaScript(script, jbCefBrowser.cefBrowser.url, 0) | ||
JBCefJSQuery.Response("success") | ||
} | ||
return@addHandler JBCefJSQuery.Response("success") | ||
} | ||
|
||
return object : CefLoadHandlerAdapter() { | ||
override fun onLoadEnd(browser: CefBrowser, frame: CefFrame, httpStatusCode: Int) { | ||
if (frame.isMain) { | ||
val script = """ | ||
(function() { | ||
if (window.aiFixQuery) { | ||
return; | ||
} | ||
window.aiFixQuery = function(value) { ${aiFixQuery.inject("value")} }; | ||
const aiFixButton = document.getElementById('generate-ai-fix'); | ||
const retryFixButton = document.getElementById('retry-generate-fix'); | ||
const issueId = aiFixButton.getAttribute('issue-id'); | ||
const folderPath = aiFixButton.getAttribute('folder-path'); | ||
const filePath = aiFixButton.getAttribute('file-path'); | ||
aiFixButton.addEventListener('click', () => { | ||
window.aiFixQuery(folderPath + ":" + filePath + ":" + issueId); | ||
}); | ||
retryFixButton.addEventListener('click', () => { | ||
window.aiFixQuery(folderPath + ":" + filePath + ":" + issueId); | ||
}); | ||
})(); | ||
""" | ||
browser.executeJavaScript(script, browser.url, 0) | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.