Skip to content

Commit

Permalink
feat: display AI Fix ⚡️ [IDE-580] (#596)
Browse files Browse the repository at this point in the history
* 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
4 people authored Sep 20, 2024
1 parent a790ab7 commit 2c09695
Show file tree
Hide file tree
Showing 13 changed files with 664 additions and 30 deletions.
110 changes: 110 additions & 0 deletions src/main/kotlin/io/snyk/plugin/DiffPatcher.kt
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 src/main/kotlin/io/snyk/plugin/ui/jcef/ApplyFixHandler.kt
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 src/main/kotlin/io/snyk/plugin/ui/jcef/GenerateAIFixHandler.kt
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)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.ui.jcef.JBCefBrowserBase
import com.intellij.util.ui.JBUI
import com.intellij.ui.JBColor
import com.intellij.util.ui.UIUtil
import org.cef.browser.CefBrowser
import org.cef.browser.CefFrame
Expand Down Expand Up @@ -57,20 +58,21 @@ class ThemeBasedStylingGenerator {
httpStatusCode: Int,
) {
if (frame.isMain) {
val baseColor = UIUtil.getTextFieldBackground()
val (addedColor, removedColor) = getCodeDiffColors(baseColor, isHighContrast)
val baseColor = UIUtil.getTextFieldBackground() //TODO Replace with JBUI.CurrentTheme colors
val (addedColor, removedColor) = getCodeDiffColors(baseColor, isHighContrast) //TODO Replace with JBUI.CurrentTheme colors
val dataFlowColor = toCssHex(baseColor)
val editorColor = toCssHex(baseColor)

val textColor = toCssHex(JBUI.CurrentTheme.Label.foreground())
val textColor = toCssHex(JBUI.CurrentTheme.Tree.FOREGROUND)
val linkColor = toCssHex(JBUI.CurrentTheme.Link.Foreground.ENABLED)
val dataFlowColor = toCssHex(baseColor)
val buttonColor = toCssHex(JBUI.CurrentTheme.Button.defaultButtonColorStart())
val borderColor = toCssHex(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
val editorColor = toCssHex(UIUtil.getTextFieldBackground())
val labelColor = toCssHex(JBUI.CurrentTheme.Label.foreground())

val globalScheme = EditorColorsManager.getInstance().globalScheme
val tearLineColor = globalScheme.getColor(ColorKey.find("TEARLINE_COLOR")) // The closest color to target_rgb = (198, 198, 200)
val tabItemHoverColor = globalScheme.getColor(ColorKey.find("INDENT_GUIDE")) // The closest color to target_rgb = RGB (235, 236, 240)
val codeTagBgColor = globalScheme.getColor(EditorColors.GUTTER_BACKGROUND) ?: globalScheme.defaultBackground
val tearLineColor = globalScheme.getColor(ColorKey.find("TEARLINE_COLOR")) //TODO Replace with JBUI.CurrentTheme colors
val tabItemHoverColor = globalScheme.getColor(ColorKey.find("INDENT_GUIDE")) //TODO Replace with JBUI.CurrentTheme colors
val codeTagBgColor = globalScheme.getColor(EditorColors.GUTTER_BACKGROUND) ?: globalScheme.defaultBackground //TODO Replace with JBUI.CurrentTheme colors

val themeScript = """
(function(){
Expand All @@ -92,7 +94,11 @@ class ThemeBasedStylingGenerator {
'--border-color': "$borderColor",
'--editor-color': "$editorColor",
'--label-color': "'$labelColor'",
'--vulnerability-overview-pre-background-color': "${toCssHex(codeTagBgColor)}",
'--container-background-color': "${toCssHex(codeTagBgColor)}",
'--generated-ai-fix-button-background-color': "$buttonColor",
'--dark-button-border-default': "$borderColor",
'--dark-button-default': "$buttonColor",
};
for (let [property, value] of Object.entries(properties)) {
document.documentElement.style.setProperty(property, value);
Expand Down
Loading

0 comments on commit 2c09695

Please sign in to comment.