diff --git a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java index 327da029..bebe02bb 100644 --- a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java +++ b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java @@ -70,6 +70,15 @@ public final class CompletionRequestService { return getChatCompletion(request); } + public EventSource getCodeEditsAsync( + AutoApplyParameters params, + CompletionEventListener eventListener) { + var request = CompletionRequestFactory + .getFactory(GeneralSettings.getSelectedService()) + .createAutoApplyRequest(params); + return getChatCompletionAsync(request, eventListener); + } + public EventSource getCommitMessageAsync( CommitMessageCompletionParameters params, CompletionEventListener eventListener) { diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java index 9bde1abd..e9e7696a 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -471,8 +471,6 @@ public class ChatToolWindowTabPanel implements Disposable { var messageResponseBody = new ChatMessageResponseBody(project, this).withResponse(response); - messageResponseBody.hideCaret(); - var responseMessagePanel = new ResponseMessagePanel(); responseMessagePanel.addContent(messageResponseBody); responseMessagePanel.addCopyAction(() -> CopyAction.copyToClipboard(message.getResponse())); diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/InsertAtCaretAction.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/InsertAtCaretAction.java index a8160cb0..b1d2b38b 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/InsertAtCaretAction.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/InsertAtCaretAction.java @@ -2,37 +2,37 @@ package ee.carlrobert.codegpt.toolwindow.chat.editor.actions; import static com.intellij.openapi.application.ActionsKt.runUndoTransparentWriteAction; -import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.ex.EditorEx; import com.intellij.openapi.fileEditor.FileEditorManager; import ee.carlrobert.codegpt.CodeGPTBundle; import ee.carlrobert.codegpt.Icons; -import ee.carlrobert.codegpt.actions.ActionType; -import ee.carlrobert.codegpt.actions.TrackableAction; import ee.carlrobert.codegpt.ui.OverlayUtil; import java.awt.Point; +import java.awt.event.ActionEvent; import java.util.Optional; +import javax.swing.AbstractAction; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public class InsertAtCaretAction extends TrackableAction { +public class InsertAtCaretAction extends AbstractAction { private final @NotNull Editor toolwindowEditor; + private final @Nullable Point locationOnScreen; - public InsertAtCaretAction(@NotNull Editor toolwindowEditor) { + public InsertAtCaretAction( + @NotNull EditorEx toolwindowEditor, + @Nullable Point locationOnScreen) { super( CodeGPTBundle.get("toolwindow.chat.editor.action.insertAtCaret.title"), - CodeGPTBundle.get("toolwindow.chat.editor.action.insertAtCaret.description"), - Icons.SendToTheLeft, - ActionType.INSERT_AT_CARET); + Icons.SendToTheLeft); this.toolwindowEditor = toolwindowEditor; + this.locationOnScreen = locationOnScreen; } @Override - public void handleAction(@NotNull AnActionEvent event) { - Point locationOnScreen = getLocationOnScreen(event); + public void actionPerformed(ActionEvent e) { Editor mainEditor = getSelectedTextEditor(); - if (mainEditor == null) { OverlayUtil.showWarningBalloon("Active editor not found", locationOnScreen); return; @@ -41,13 +41,6 @@ public class InsertAtCaretAction extends TrackableAction { insertTextAtCaret(mainEditor); } - @Nullable - private Point getLocationOnScreen(AnActionEvent event) { - return Optional.ofNullable(event.getInputEvent()) - .map(inputEvent -> inputEvent.getComponent().getLocationOnScreen()) - .orElse(null); - } - @Nullable private Editor getSelectedTextEditor() { return Optional.ofNullable(toolwindowEditor.getProject()) diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java index 3c73f3ba..b8cd21f6 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java @@ -17,6 +17,7 @@ import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.options.ShowSettingsUtil; import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.VerticalFlowLayout; import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; @@ -39,10 +40,18 @@ import ee.carlrobert.codegpt.settings.service.ServiceType; import ee.carlrobert.codegpt.telemetry.TelemetryAction; import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel; import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction; -import ee.carlrobert.codegpt.toolwindow.chat.parser.CompleteOutputParser; -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamOutputParser; -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse; -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse.StreamResponseType; +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DefaultHeaderPanel; +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DiffHeaderPanel; +import ee.carlrobert.codegpt.toolwindow.chat.parser.Code; +import ee.carlrobert.codegpt.toolwindow.chat.parser.CodeEnd; +import ee.carlrobert.codegpt.toolwindow.chat.parser.CompleteMessageParser; +import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting; +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace; +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchWaiting; +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment; +import ee.carlrobert.codegpt.toolwindow.chat.parser.SseMessageParser; +import ee.carlrobert.codegpt.toolwindow.chat.parser.Text; +import ee.carlrobert.codegpt.toolwindow.chat.parser.Thinking; import ee.carlrobert.codegpt.toolwindow.ui.ResponseBodyProgressPanel; import ee.carlrobert.codegpt.toolwindow.ui.WebpageList; import ee.carlrobert.codegpt.ui.ThoughtProcessPanel; @@ -51,13 +60,13 @@ import ee.carlrobert.codegpt.util.EditorUtil; import java.awt.BorderLayout; import java.util.Objects; import java.util.stream.Stream; -import javax.swing.BoxLayout; import javax.swing.DefaultListModel; import javax.swing.JEditorPane; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JTextPane; import javax.swing.event.HyperlinkListener; +import kotlin.jvm.Synchronized; import org.jetbrains.annotations.NotNull; public class ChatMessageResponseBody extends JPanel { @@ -66,13 +75,15 @@ public class ChatMessageResponseBody extends JPanel { private final Project project; private final Disposable parentDisposable; - private final StreamOutputParser streamOutputParser; + private final SseMessageParser streamOutputParser; private final boolean readOnly; private final DefaultListModel webpageListModel = new DefaultListModel<>(); private final WebpageList webpageList = new WebpageList(webpageListModel); private final ResponseBodyProgressPanel progressPanel = new ResponseBodyProgressPanel(); private final JPanel loadingLabel = createLoadingPanel(); - private final JPanel contentPanel = new JPanel(); + private final JPanel contentPanel = + new JPanel(new VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 4, true, false)); + private ResponseEditorPanel currentlyProcessedEditorPanel; private JEditorPane currentlyProcessedTextPane; private JPanel webpageListPanel; @@ -99,13 +110,12 @@ public class ChatMessageResponseBody extends JPanel { Disposable parentDisposable) { this.project = project; this.parentDisposable = parentDisposable; - this.streamOutputParser = new StreamOutputParser(); + this.streamOutputParser = new SseMessageParser(); this.readOnly = readOnly; setLayout(new BorderLayout()); setOpaque(false); - contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); contentPanel.setOpaque(false); add(contentPanel, BorderLayout.NORTH); @@ -126,9 +136,8 @@ public class ChatMessageResponseBody extends JPanel { public ChatMessageResponseBody withResponse(@NotNull String response) { try { - for (var item : new CompleteOutputParser().parse(response)) { - processResponse(item, false); - + for (var item : new CompleteMessageParser().parse(response)) { + processResponse(item, false, false); currentlyProcessedTextPane = null; currentlyProcessedEditorPanel = null; } @@ -148,8 +157,8 @@ public class ChatMessageResponseBody extends JPanel { } var parsedResponse = streamOutputParser.parse(partialMessage); - for (StreamParseResponse item : parsedResponse) { - processResponse(item, true); + for (Segment item : parsedResponse) { + processResponse(item, true, true); } } @@ -182,30 +191,26 @@ public class ChatMessageResponseBody extends JPanel { } public void handleCodeGPTEvent(CodeGPTEvent codegptEvent) { - ApplicationManager.getApplication() - .invokeLater(() -> { - var event = codegptEvent.getEvent(); - if (event.getDetails() instanceof WebSearchEventDetails webSearchEventDetails) { - displayWebSearchItem(webSearchEventDetails); - return; + ApplicationManager.getApplication().invokeLater(() -> { + var event = codegptEvent.getEvent(); + if (event.getDetails() instanceof WebSearchEventDetails webSearchEventDetails) { + displayWebSearchItem(webSearchEventDetails); + return; + } + switch (event.getType()) { + case WEB_SEARCH_ITEM -> { + if (event.getDetails() instanceof WebSearchEventDetails details) { + displayWebSearchItem(details); } - - switch (event.getType()) { - case WEB_SEARCH_ITEM -> { - if (event.getDetails() != null - && event.getDetails() instanceof WebSearchEventDetails eventDetails) { - displayWebSearchItem(eventDetails); - } - } - - case ANALYZE_WEB_DOC_STARTED -> showWebDocsProgress(); - case ANALYZE_WEB_DOC_COMPLETED -> completeWebDocsProgress(event.getDetails()); - case ANALYZE_WEB_DOC_FAILED -> failWebDocsProgress(event.getDetails()); - case PROCESS_CONTEXT -> progressPanel.updateProgressDetails(event.getDetails()); - default -> { - } - } - }); + } + case ANALYZE_WEB_DOC_STARTED -> showWebDocsProgress(); + case ANALYZE_WEB_DOC_COMPLETED -> completeWebDocsProgress(event.getDetails()); + case ANALYZE_WEB_DOC_FAILED -> failWebDocsProgress(event.getDetails()); + case PROCESS_CONTEXT -> progressPanel.updateProgressDetails(event.getDetails()); + default -> { + } + } + }); } public void hideCaret() { @@ -219,7 +224,7 @@ public class ChatMessageResponseBody extends JPanel { streamOutputParser.clear(); loadingLabel.setVisible(false); - // TODO: First message might be code block + // Reset for the next incoming message prepareProcessingText(true); currentlyProcessedTextPane.setText( "

"); @@ -237,13 +242,14 @@ public class ChatMessageResponseBody extends JPanel { webpageListPanel.setVisible(false); } + String formattedMessage = format( + "

%s

", message); + if (currentlyProcessedTextPane == null) { - currentlyProcessedTextPane = createTextPane(""); + currentlyProcessedTextPane = createTextPane(formattedMessage, false); contentPanel.add(currentlyProcessedTextPane); } - String formattedMessage = format( - "

%s

", message); currentlyProcessedTextPane.setVisible(true); currentlyProcessedTextPane.setText(formattedMessage); @@ -281,8 +287,8 @@ public class ChatMessageResponseBody extends JPanel { .orElse(null); } - private void processResponse(StreamParseResponse item, boolean caretVisible) { - if (item.getType() == StreamResponseType.THINKING) { + private void processResponse(Segment item, boolean caretVisible, boolean partialResponse) { + if (item instanceof Thinking) { processThinkingOutput(item.getContent()); return; } @@ -292,25 +298,60 @@ public class ChatMessageResponseBody extends JPanel { thoughtProcessPanel.setFinished(); } - if (item.getType() == StreamResponseType.CODE_CONTENT - || item.getType() == StreamResponseType.CODE_HEADER) { + if (item instanceof CodeEnd) { + if (currentlyProcessedEditorPanel != null) { + handleHeaderOnCompletion(currentlyProcessedEditorPanel); + } + currentlyProcessedEditorPanel = null; + return; + } + + if (item instanceof SearchReplace searchReplace) { + if (currentlyProcessedEditorPanel == null) { + prepareProcessingCode(searchReplace); + } + if (currentlyProcessedEditorPanel != null) { + currentlyProcessedEditorPanel.handleSearchReplace(searchReplace, partialResponse); + handleHeaderOnCompletion(currentlyProcessedEditorPanel); + return; + } + } + + if (item instanceof ReplaceWaiting replaceWaiting) { + if (currentlyProcessedEditorPanel != null) { + currentlyProcessedEditorPanel.handleReplace(replaceWaiting); + return; + } + } + + if (item instanceof Code || item instanceof SearchWaiting) { processCode(item); - } else { + return; + } + + if (item instanceof Text) { processText(item.getContent(), caretVisible); } } - private void processCode(StreamParseResponse item) { + private void processCode(Segment item) { var content = item.getContent(); - if (!content.isEmpty()) { - if (currentlyProcessedEditorPanel == null) { - prepareProcessingCode(item); - } - EditorUtil.updateEditorDocument(currentlyProcessedEditorPanel.getEditor(), content); + if (currentlyProcessedEditorPanel == null) { + prepareProcessingCode(item); + return; + } + + var editor = currentlyProcessedEditorPanel.getEditor(); + if (item instanceof Code && editor != null) { + EditorUtil.updateEditorDocument(editor, content); } } private void processText(String markdownText, boolean caretVisible) { + if (markdownText == null || markdownText.isEmpty()) { + return; + } + var html = convertMdToHtml(markdownText); if (currentlyProcessedTextPane == null) { prepareProcessingText(caretVisible); @@ -318,18 +359,36 @@ public class ChatMessageResponseBody extends JPanel { currentlyProcessedTextPane.setText(html); } + @Synchronized private void prepareProcessingText(boolean caretVisible) { currentlyProcessedEditorPanel = null; currentlyProcessedTextPane = createTextPane("", caretVisible); contentPanel.add(currentlyProcessedTextPane); + contentPanel.revalidate(); + contentPanel.repaint(); } - private void prepareProcessingCode(StreamParseResponse item) { + @Synchronized + private void prepareProcessingCode(Segment item) { hideCaret(); currentlyProcessedTextPane = null; currentlyProcessedEditorPanel = new ResponseEditorPanel(project, item, readOnly, parentDisposable); contentPanel.add(currentlyProcessedEditorPanel); + contentPanel.revalidate(); + contentPanel.repaint(); + } + + private void handleHeaderOnCompletion(ResponseEditorPanel editorPanel) { + var editor = editorPanel.getEditor(); + if (editor != null) { + var header = editor.getPermanentHeaderComponent(); + if (header instanceof DiffHeaderPanel diffHeaderPanel) { + diffHeaderPanel.handleDone(); + } else if (header instanceof DefaultHeaderPanel defaultHeaderPanel) { + defaultHeaderPanel.handleDone(); + } + } } private void displayWebSearchItem(WebSearchEventDetails details) { @@ -359,10 +418,6 @@ public class ChatMessageResponseBody extends JPanel { } } - private JTextPane createTextPane(String text) { - return createTextPane(text, false); - } - private JTextPane createTextPane(String text, boolean caretVisible) { var textPane = UIUtil.createTextPane(text, false, event -> { if (FileUtil.exists(event.getDescription()) && ACTIVATED.equals(event.getEventType())) { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt index dae658f9..730faa12 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt @@ -1,5 +1,6 @@ package ee.carlrobert.codegpt.completions +import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.conversations.Conversation import ee.carlrobert.codegpt.conversations.message.Message @@ -98,6 +99,8 @@ data class CommitMessageCompletionParameters( data class LookupCompletionParameters(val prompt: String) : CompletionParameters +data class AutoApplyParameters(val source: String, val destination: VirtualFile) + data class EditCodeCompletionParameters( val prompt: String, val selectedText: String diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt index d5d5e26c..d760f091 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt @@ -1,14 +1,8 @@ package ee.carlrobert.codegpt.completions import com.intellij.openapi.components.service -import ee.carlrobert.codegpt.completions.factory.AzureRequestFactory -import ee.carlrobert.codegpt.completions.factory.ClaudeRequestFactory -import ee.carlrobert.codegpt.completions.factory.CodeGPTRequestFactory -import ee.carlrobert.codegpt.completions.factory.CustomOpenAIRequestFactory -import ee.carlrobert.codegpt.completions.factory.GoogleRequestFactory -import ee.carlrobert.codegpt.completions.factory.LlamaRequestFactory -import ee.carlrobert.codegpt.completions.factory.OllamaRequestFactory -import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory +import com.intellij.openapi.vfs.readText +import ee.carlrobert.codegpt.completions.factory.* import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer import ee.carlrobert.codegpt.settings.prompts.CoreActionsState import ee.carlrobert.codegpt.settings.prompts.PromptsSettings @@ -18,6 +12,7 @@ import ee.carlrobert.llm.completion.CompletionRequest interface CompletionRequestFactory { fun createChatRequest(params: ChatCompletionParameters): CompletionRequest fun createEditCodeRequest(params: EditCodeCompletionParameters): CompletionRequest + fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest @@ -60,6 +55,22 @@ abstract class BaseRequestFactory : CompletionRequestFactory { ) } + override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest { + val prompt = buildString { + append("Source:\n") + append("${CompletionRequestUtil.formatCode(params.source)}\n\n") + append("Destination:\n") + val destination = params.destination + append( + "${CompletionRequestUtil.formatCode(destination.readText(), destination.path)}\n" + ) + } + return createBasicCompletionRequest( + service().state.coreActions.autoApply.instructions + ?: CoreActionsState.DEFAULT_AUTO_APPLY_PROMPT, prompt, 8192, true + ) + } + abstract fun createBasicCompletionRequest( systemPrompt: String, userPrompt: String, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt index eaa22f80..fd0146ab 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt @@ -21,16 +21,13 @@ object CompletionRequestUtil { """.trimIndent() @JvmStatic - fun formatCode(code: String, filePath: String?): String { - val language = filePath?.let { "${FileUtil.getFileExtension(it)}:$it" } ?: "" - - return """ - - ```$language - $code - ``` - - """.trimIndent() + fun formatCode(code: String, filePath: String? = null): String { + val header = filePath?.let { "${FileUtil.getFileExtension(it)}:$it" } ?: "" + return buildString { + append("```${header}\n") + append("$code\n") + append("```\n") + } } @JvmStatic @@ -43,12 +40,7 @@ object CompletionRequestUtil { val repeatableContext = includedFilesSettings.repeatableContext val fileContext = referencedFiles.stream() .map { item: ReferencedFile -> - repeatableContext - .replace("{FILE_PATH}", item.filePath()) - .replace( - "{FILE_CONTENT}", - formatCode(item.fileContent(), item.filePath()) - ) + formatCode(item.fileContent(), item.filePath()) } .collect(Collectors.joining("\n\n")) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt index f1be4191..6a0d43f2 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt @@ -1,6 +1,7 @@ package ee.carlrobert.codegpt.completions.factory import com.intellij.openapi.components.service +import com.intellij.openapi.vfs.readText import ee.carlrobert.codegpt.EncodingManager import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.completions.* @@ -15,6 +16,7 @@ import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings import ee.carlrobert.codegpt.util.file.FileUtil.getImageMediaType import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.* import ee.carlrobert.llm.client.openai.completion.request.* +import ee.carlrobert.llm.completion.CompletionRequest import java.io.IOException import java.nio.file.Files import java.nio.file.Path @@ -53,6 +55,26 @@ class OpenAIRequestFactory : CompletionRequestFactory { return createBasicCompletionRequest(systemPrompt, prompt, model, true) } + override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest { + val model = service().state.model + val systemPrompt = service().state.coreActions.autoApply.instructions + ?: CoreActionsState.DEFAULT_AUTO_APPLY_PROMPT + + val prompt = buildString { + append("Source:\n") + append("${CompletionRequestUtil.formatCode(params.source)}\n\n") + append("Destination:\n") + val destination = params.destination + append( + "${CompletionRequestUtil.formatCode(destination.readText(), destination.path)}\n" + ) + } + if (isReasoningModel(model)) { + return buildBasicO1Request(model, prompt, systemPrompt, stream = true) + } + return createBasicCompletionRequest(systemPrompt, prompt, model, true) + } + override fun createCommitMessageRequest(params: CommitMessageCompletionParameters): OpenAIChatCompletionRequest { val model = service().state.model val (gitDiff, systemPrompt) = params diff --git a/src/main/kotlin/ee/carlrobert/codegpt/predictions/CodeSuggestionDiffViewer.kt b/src/main/kotlin/ee/carlrobert/codegpt/predictions/CodeSuggestionDiffViewer.kt index cd265a9d..b9a0cdca 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/predictions/CodeSuggestionDiffViewer.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/predictions/CodeSuggestionDiffViewer.kt @@ -262,7 +262,7 @@ class CodeSuggestionDiffViewer( myEditor.component.repaint() } - private class MyDiffContext(private val project: Project?) : DiffContext() { + class MyDiffContext(private val project: Project?) : DiffContext() { private val ownContext: UserDataHolder = UserDataHolderBase() override fun getProject() = project diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt index fe83a702..81d60b51 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt @@ -9,6 +9,11 @@ import ee.carlrobert.codegpt.CodeGPTBundle class ChatCompletionConfigurationForm { + private val retryOnFailedDiffSearchCheckBox = JBCheckBox( + CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.retryOnFailedDiffSearch.title"), + service().state.chatCompletionSettings.retryOnFailedDiffSearchEnabled + ) + private val editorContextTagCheckBox = JBCheckBox( CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.editorContextTag.title"), service().state.chatCompletionSettings.editorContextTagEnabled @@ -21,6 +26,10 @@ class ChatCompletionConfigurationForm { fun createPanel(): DialogPanel { return panel { + row { + cell(retryOnFailedDiffSearchCheckBox) + .comment(CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.retryOnFailedDiffSearch.description")) + } row { cell(editorContextTagCheckBox) .comment(CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.editorContextTag.description")) @@ -33,12 +42,14 @@ class ChatCompletionConfigurationForm { } fun resetForm(prevState: ChatCompletionSettingsState) { + retryOnFailedDiffSearchCheckBox.isSelected = prevState.retryOnFailedDiffSearchEnabled editorContextTagCheckBox.isSelected = prevState.editorContextTagEnabled psiStructureCheckBox.isSelected = prevState.psiStructureEnabled } fun getFormState(): ChatCompletionSettingsState { return ChatCompletionSettingsState().apply { + this.retryOnFailedDiffSearchEnabled = retryOnFailedDiffSearchCheckBox.isSelected this.editorContextTagEnabled = editorContextTagCheckBox.isSelected this.psiStructureEnabled = psiStructureCheckBox.isSelected } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt index d3d22502..aa6043db 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt @@ -41,6 +41,7 @@ class ConfigurationSettingsState : BaseState() { } class ChatCompletionSettingsState : BaseState() { + var retryOnFailedDiffSearchEnabled by property(true) var editorContextTagEnabled by property(true) var psiStructureEnabled by property(true) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt index adc1ed4d..bb12e8b3 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt @@ -4,13 +4,8 @@ import com.intellij.ide.impl.ProjectUtil import com.intellij.openapi.components.* import com.intellij.openapi.project.guessProjectDir import ee.carlrobert.codegpt.actions.editor.EditorActionsUtil -import ee.carlrobert.codegpt.codecompletions.InfillPromptTemplate -import ee.carlrobert.codegpt.credentials.CredentialsStore import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.persona.PersonaSettings -import ee.carlrobert.codegpt.settings.service.custom.CustomServiceSettings -import ee.carlrobert.codegpt.settings.service.custom.CustomServiceSettingsState -import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceTemplate import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent @Service @@ -50,6 +45,8 @@ class PromptsSettingsState : BaseState() { class CoreActionsState : BaseState() { companion object { + val DEFAULT_AUTO_APPLY_PROMPT = + getResourceContent("/prompts/core/auto-apply.txt") val DEFAULT_EDIT_CODE_PROMPT = getResourceContent("/prompts/core/edit-code.txt") val DEFAULT_GENERATE_COMMIT_MESSAGE_PROMPT = getResourceContent("/prompts/core/generate-commit-message.txt") @@ -61,6 +58,11 @@ class CoreActionsState : BaseState() { getResourceContent("/prompts/core/review-changes.txt") } + var autoApply by property(CoreActionPromptDetailsState().apply { + name = "Auto Apply" + code = "AUTO_APPLY" + instructions = DEFAULT_AUTO_APPLY_PROMPT + }) var editCode by property(CoreActionPromptDetailsState().apply { name = "Edit Code" code = "EDIT_CODE" diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt index c92d439f..1feab8a2 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt @@ -158,11 +158,12 @@ class PromptsForm { val coreActionsFormState = getFormState(coreActionsNode) settings.coreActions.apply { - editCode = coreActionsFormState[0].toState() - fixCompileErrors = coreActionsFormState[1].toState() - generateCommitMessage = coreActionsFormState[2].toState() - generateNameLookups = coreActionsFormState[3].toState() - reviewChanges = coreActionsFormState[4].toState() + autoApply = coreActionsFormState[0].toState() + editCode = coreActionsFormState[1].toState() + fixCompileErrors = coreActionsFormState[2].toState() + generateCommitMessage = coreActionsFormState[3].toState() + generateNameLookups = coreActionsFormState[4].toState() + reviewChanges = coreActionsFormState[5].toState() } settings.chatActions.prompts = getFormState(chatActionsNode) .map { it.toState() } @@ -214,6 +215,7 @@ class PromptsForm { val formState = getFormState(coreActionsNode) val stateActions = listOf( + settingsState.autoApply, settingsState.editCode, settingsState.fixCompileErrors, settingsState.generateCommitMessage, @@ -269,6 +271,7 @@ class PromptsForm { val settings = service().state listOf( + settings.coreActions.autoApply, settings.coreActions.editCode, settings.coreActions.fixCompileErrors, settings.coreActions.generateCommitMessage, @@ -588,6 +591,7 @@ class PromptsForm { private fun insertCorePrompts(prompts: CoreActionsState) { coreActionsNode.removeAllChildren() listOf( + prompts.autoApply, prompts.editCode, prompts.fixCompileErrors, prompts.generateCommitMessage, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/details/CoreActionsDetailsPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/details/CoreActionsDetailsPanel.kt index c2b02f10..5cf8b9d8 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/details/CoreActionsDetailsPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/details/CoreActionsDetailsPanel.kt @@ -11,6 +11,7 @@ import com.intellij.util.ui.components.BorderLayoutPanel import ee.carlrobert.codegpt.settings.Placeholder import ee.carlrobert.codegpt.settings.Placeholder.GIT_DIFF import ee.carlrobert.codegpt.settings.prompts.CommitMessageTemplate +import ee.carlrobert.codegpt.settings.prompts.CoreActionsState.Companion.DEFAULT_AUTO_APPLY_PROMPT import ee.carlrobert.codegpt.settings.prompts.CoreActionsState.Companion.DEFAULT_EDIT_CODE_PROMPT import ee.carlrobert.codegpt.settings.prompts.CoreActionsState.Companion.DEFAULT_FIX_COMPILE_ERRORS_PROMPT import ee.carlrobert.codegpt.settings.prompts.CoreActionsState.Companion.DEFAULT_GENERATE_COMMIT_MESSAGE_PROMPT @@ -29,6 +30,12 @@ class CoreActionsDetailsPanel : PromptDetailsPanel { override fun create(details: CoreActionPromptDetails): JComponent { val editorPanel = when (details.code) { + "AUTO_APPLY" -> CoreActionEditorPanel( + details, + DEFAULT_AUTO_APPLY_PROMPT, + "Template used for the 'Auto Apply' feature." + ) + "EDIT_CODE" -> CoreActionEditorPanel( details, DEFAULT_EDIT_CODE_PROMPT, @@ -70,6 +77,7 @@ class CoreActionsDetailsPanel : PromptDetailsPanel { init { val settings = service().state.coreActions listOf( + settings.autoApply, settings.editCode, settings.fixCompileErrors, settings.generateCommitMessage, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/HeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/HeaderPanel.kt deleted file mode 100644 index 23111d36..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/HeaderPanel.kt +++ /dev/null @@ -1,157 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat.editor - -import com.intellij.icons.AllIcons.General -import com.intellij.ide.actions.OpenFileAction -import com.intellij.openapi.actionSystem.* -import com.intellij.openapi.actionSystem.toolbarLayout.ToolbarLayoutStrategy -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.editor.ex.EditorEx -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.JBMenuItem -import com.intellij.openapi.ui.JBPopupMenu -import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.ui.ColorUtil -import com.intellij.ui.JBColor -import com.intellij.ui.components.ActionLink -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import ee.carlrobert.codegpt.CodeGPTKeys -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.* -import java.awt.BorderLayout -import javax.swing.JComponent -import javax.swing.JPanel - -class HeaderPanel( - private val project: Project, - private val editorEx: EditorEx, - filePath: String?, - private val extension: String, - private val readOnly: Boolean -) : JPanel(BorderLayout()) { - - private var actionToolbar: ActionToolbar? = null - - init { - setupPanelAppearance() - setupFilePathOrLanguageLabel(filePath) - - if (!readOnly) { - actionToolbar = createHeaderActions() - add(actionToolbar!!.component, BorderLayout.LINE_END) - } - } - - private fun setupPanelAppearance() { - border = JBUI.Borders.compound( - JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 1, 1, 0, 1), - JBUI.Borders.empty(4) - ) - } - - private fun setupFilePathOrLanguageLabel(filePath: String?) { - val application = ApplicationManager.getApplication() - if (filePath != null) { - application.executeOnPooledThread { - val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath) - if (virtualFile == null) { - addComponent(createLanguageLabel(extension)) - } else { - addComponent(createFileLink(virtualFile)) - } - CodeGPTKeys.TOOLWINDOW_EDITOR_FILE_DETAILS.set( - editorEx, - ToolWindowEditorFileDetails(filePath, virtualFile) - ) - } - } else { - addComponent(createLanguageLabel(extension)) - } - } - - private fun addComponent(component: JComponent) { - runInEdt { - add(component, BorderLayout.LINE_START) - } - } - - private fun createFileLink(virtualFile: VirtualFile): ActionLink { - val name = virtualFile.name - val fileActionLink = ActionLink(name) - fileActionLink.setExternalLinkIcon() - fileActionLink.addActionListener { OpenFileAction.openFile(virtualFile, project) } - return fileActionLink - } - - private fun createLanguageLabel(language: String): JBLabel { - val label = JBLabel(language) - label.border = JBUI.Borders.emptyLeft(4) - label.foreground = JBColor.GRAY - return label - } - - private fun createHeaderActions(): ActionToolbar { - val actionGroup = DefaultActionGroup("EDITOR_TOOLBAR_ACTION_GROUP", false) - actionGroup.add(AutoApplyAction(project, editorEx, this)) - actionGroup.add(InsertAtCaretAction(editorEx)) - actionGroup.add(CopyAction(editorEx)) - actionGroup.addSeparator() - actionGroup.add(createGearAction()) - - val toolbar = ActionManager.getInstance() - .createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true) - toolbar.layoutStrategy = ToolbarLayoutStrategy.NOWRAP_STRATEGY - toolbar.targetComponent = this - toolbar.component.border = JBUI.Borders.empty() - toolbar.updateActionsAsync() - return toolbar - } - - private fun createGearActionsMenu(): JBPopupMenu { - val menu = JBPopupMenu() - menu.add(JBMenuItem(DiffAction(editorEx, menu.location))) - menu.add(JBMenuItem(ReplaceSelectionAction(editorEx, menu.location))) - menu.add(JBMenuItem(EditAction(editorEx))) - menu.add(JBMenuItem(NewFileAction(editorEx, extension))) - return menu - } - - private fun createGearAction(): AnAction { - return object : AnAction("Editor Actions", "Editor actions", General.GearPlain) { - override fun actionPerformed(e: AnActionEvent) { - val inputEvent = e.inputEvent - if (inputEvent != null) { - createGearActionsMenu().show(inputEvent.component, 0, 0) - } - } - } - } - - fun setRightComponent(component: JPanel) { - if (!readOnly) { - remove(actionToolbar!!.component) - add(component, BorderLayout.LINE_END) - revalidate() - repaint() - } - } - - fun restoreActionToolbar() { - if (!readOnly) { - val components = components - for (component in components) { - if (layout is BorderLayout && - (layout as BorderLayout).getConstraints(component) == BorderLayout.LINE_END - ) { - remove(component) - } - } - - add(actionToolbar!!.component, BorderLayout.LINE_END) - actionToolbar!!.updateActionsAsync() - revalidate() - repaint() - } - } -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt index c9f2f220..26f2c5b9 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt @@ -1,218 +1,163 @@ package ee.carlrobert.codegpt.toolwindow.chat.editor +import com.intellij.diff.tools.fragmented.UnifiedDiffViewer import com.intellij.openapi.Disposable -import com.intellij.openapi.actionSystem.ActionGroup -import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorKind import com.intellij.openapi.editor.LogicalPosition import com.intellij.openapi.editor.ScrollType -import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.BulkAwareDocumentListener import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.ex.EditorEx -import com.intellij.openapi.editor.impl.ContextMenuPopupHandler import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key -import com.intellij.openapi.util.text.StringUtil -import com.intellij.ui.ColorUtil -import com.intellij.ui.IdeBorderFactory -import com.intellij.ui.JBColor -import com.intellij.ui.components.ActionLink import com.intellij.util.ui.JBUI -import ee.carlrobert.codegpt.CodeGPTBundle -import ee.carlrobert.codegpt.Icons -import ee.carlrobert.codegpt.actions.toolwindow.ReplaceCodeInMainEditorAction -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse -import ee.carlrobert.codegpt.util.EditorUtil -import ee.carlrobert.codegpt.util.file.FileUtil.findLanguageExtensionMapping -import java.awt.BorderLayout -import java.awt.Dimension -import java.awt.FlowLayout -import javax.swing.JPanel -import javax.swing.SwingConstants +import com.intellij.util.ui.components.BorderLayoutPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.diff.DiffSyncManager +import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.ComponentFactory +import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.ComponentFactory.EXPANDED_KEY +import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.ComponentFactory.MIN_LINES_FOR_EXPAND +import ee.carlrobert.codegpt.toolwindow.chat.editor.state.EditorState +import ee.carlrobert.codegpt.toolwindow.chat.editor.state.EditorStateManager +import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment class ResponseEditorPanel( project: Project, - item: StreamParseResponse, + item: Segment, readOnly: Boolean, - disposableParent: Disposable -) : JPanel(BorderLayout()), Disposable { + disposableParent: Disposable, +) : BorderLayoutPanel(), Disposable { companion object { - private val EXPANDED_KEY = Key.create("toolwindow.editor.isExpanded") - private const val MAX_VISIBLE_LINES = 10 - private const val MIN_LINES_FOR_EXPAND = 8 + val RESPONSE_EDITOR_DIFF_VIEWER_KEY = + Key.create("proxyai.responseEditorDiffViewer") + val RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY = + Key.create>("proxyai.responseEditorDiffViewerValuePair") + val RESPONSE_EDITOR_STATE_KEY = Key.create("proxyai.responseEditorState") } - val editor: Editor - private val expandLinkPanel: JPanel - private var expandLinkAdded = false + private val stateManager = project.service() + private var searchReplaceHandler: SearchReplaceHandler init { border = JBUI.Borders.empty(8, 0) isOpaque = false - val languageMapping = findLanguageExtensionMapping(item.language) - editor = EditorUtil.createEditor( - project, - languageMapping.value, - StringUtil.convertLineSeparators(item.content) - ) - - val group = DefaultActionGroup().apply { - add(ReplaceCodeInMainEditorAction()) - - (editor as EditorEx).contextMenuGroupId?.let { groupId -> - val actionManager = ActionManager.getInstance() - val originalGroup = actionManager.getAction(groupId) - if (originalGroup is ActionGroup) { - addAll(originalGroup.getChildren(null, actionManager).toList()) - } - } + val state = stateManager.createFromSegment(item, readOnly) + val editor = state.editor + configureEditor(editor) + searchReplaceHandler = SearchReplaceHandler(stateManager) { oldEditor, newEditor -> + replaceEditor(oldEditor, newEditor) } - configureEditor( - project, - editor as EditorEx, - readOnly, - ContextMenuPopupHandler.Simple(group), - item.filePath ?: "", - languageMapping.key - ) + addToCenter(editor.component) + updateEditorUI() - add(editor.component, BorderLayout.CENTER) - - expandLinkPanel = JPanel(FlowLayout(FlowLayout.CENTER)).apply { - isOpaque = false - border = JBUI.Borders.compound( - JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 0, 1, 1, 1), - JBUI.Borders.empty(0) - ) - add(createLink(editor)) - } - - editor.document.addDocumentListener(object : BulkAwareDocumentListener.Simple { - override fun documentChanged(event: DocumentEvent) { - updateEditorHeightAndUI() - scrollToEnd() - } - }) - - if (editor.document.text.lines().size >= MIN_LINES_FOR_EXPAND) { - updateEditorHeightAndUI() - } Disposer.register(disposableParent, this) } - override fun dispose() { - EditorFactory.getInstance().releaseEditor(editor) - } - - private fun configureEditor( - project: Project, - editorEx: EditorEx, - readOnly: Boolean, - popupHandler: ContextMenuPopupHandler, - filePath: String, - language: String - ) { - EXPANDED_KEY.set(editorEx, false) - - editorEx.installPopupHandler(popupHandler) - editorEx.colorsScheme = EditorColorsManager.getInstance().schemeForCurrentUITheme - - editorEx.settings.apply { - additionalColumnsCount = 0 - additionalLinesCount = 0 - isAdditionalPageAtBottom = false - isVirtualSpace = false - isUseSoftWraps = false - isLineMarkerAreaShown = false - isLineNumbersShown = false - } - - editorEx.gutterComponentEx.apply { - isVisible = true - parent.isVisible = false - } - - editorEx.contentComponent.border = JBUI.Borders.emptyLeft(4) - editorEx.setBorder(IdeBorderFactory.createBorder(ColorUtil.fromHex("#48494b"))) - editorEx.permanentHeaderComponent = - HeaderPanel(project, editorEx, filePath, language, readOnly) - editorEx.headerComponent = null - } - - private fun getLinkText(expanded: Boolean): String { - return if (expanded) { - CodeGPTBundle.get("toolwindow.chat.editor.action.collapse") - } else { - CodeGPTBundle.get("toolwindow.chat.editor.action.expand") - } - } - - private fun createLink(editorEx: EditorEx): ActionLink { - val isExpanded = EXPANDED_KEY.get(editorEx) ?: false - val linkText = getLinkText(isExpanded) - - return ActionLink(linkText) { event -> - val currentState = EXPANDED_KEY.get(editorEx) ?: false - val newState = !currentState - EXPANDED_KEY.set(editorEx, newState) - - val source = event.source as ActionLink - source.text = getLinkText(newState) - source.icon = if (newState) Icons.CollapseAll else Icons.ExpandAll - - if (newState) { - editorEx.component.preferredSize = null - } else { - updateEditorHeightAndUI() + private fun configureEditor(editor: EditorEx) { + editor.document.addDocumentListener(object : BulkAwareDocumentListener.Simple { + override fun documentChanged(event: DocumentEvent) { + runInEdt { + updateEditorUI() + if (editor.editorKind != EditorKind.DIFF) { + scrollToEnd() + } + } } + }) + } - editorEx.component.revalidate() - editorEx.component.repaint() - }.apply { - icon = if (isExpanded) Icons.CollapseAll else Icons.ExpandAll - font = JBUI.Fonts.smallFont() - foreground = JBColor.GRAY - horizontalAlignment = SwingConstants.CENTER + private fun updateEditorUI() { + updateEditorHeightAndUI() + updateExpandLinkVisibility() + } + + override fun dispose() { + val state = stateManager.getCurrentState() + val editor = state?.editor ?: return + val filePath = state.segment.filePath + if (filePath != null) { + DiffSyncManager.unregisterEditor(filePath, editor) } } + fun handleSearchReplace(item: SearchReplace, partialResponse: Boolean) { + searchReplaceHandler.handleSearchReplace(item, partialResponse) + } + + fun handleReplace(item: ReplaceWaiting) { + searchReplaceHandler.handleReplace(item) + } + + fun getEditor(): EditorEx? { + return stateManager.getCurrentState()?.editor + } + + fun replaceEditor(oldEditor: EditorEx, newEditor: EditorEx) { + runInEdt { + val expanded = oldEditor.getUserData(EXPANDED_KEY) == true + EXPANDED_KEY.set(newEditor, expanded) + + removeAll() + + configureEditor(newEditor) + addToCenter(newEditor.component) + + ComponentFactory.updateEditorPreferredSize(newEditor, expanded) + updateEditorUI() + + revalidate() + repaint() + } + } + + fun removeEditorAndAuxiliaryPanels() { + removeAll() + revalidate() + repaint() + } + private fun updateEditorHeightAndUI() { - (editor as? EditorEx)?.let { editorEx -> - val lineHeight = editorEx.lineHeight - val lineCount = editor.document.lineCount - val isExpanded = EXPANDED_KEY.get(editorEx) ?: false + val editor = stateManager.getCurrentState()?.editor ?: return + ComponentFactory.updateEditorPreferredSize( + editor, + editor.getUserData(EXPANDED_KEY) == true + ) + } - if (lineCount > MIN_LINES_FOR_EXPAND && !expandLinkAdded) { - add(expandLinkPanel, BorderLayout.SOUTH) - expandLinkAdded = true + private fun updateExpandLinkVisibility() { + val editor = stateManager.getCurrentState()?.editor ?: return + if (componentCount == 0 || getComponent(0) !== editor.component) { + return + } + + val lineCount = editor.document.lineCount + val shouldShowExpandLink = lineCount >= MIN_LINES_FOR_EXPAND + + val hasExpandLink = componentCount > 1 && getComponent(1) != null + + if (shouldShowExpandLink && !hasExpandLink) { + val expandLinkPanel = ComponentFactory.createExpandLinkPanel(editor) + addToBottom(expandLinkPanel) + revalidate() + repaint() + } else if (!shouldShowExpandLink && hasExpandLink) { + if (componentCount > 1) { + remove(getComponent(1)) revalidate() repaint() } - - if (lineCount <= MIN_LINES_FOR_EXPAND) { - return - } - - if (!isExpanded) { - val visibleLines = lineCount.coerceAtMost(MAX_VISIBLE_LINES) - val desiredHeight = (lineHeight * visibleLines).coerceAtLeast(20) - - editor.component.preferredSize = Dimension(editor.component.width, desiredHeight) - } - - editor.component.revalidate() - editor.component.repaint() } } private fun scrollToEnd() { + val editor = stateManager.getCurrentState()?.editor ?: return val textLength = editor.document.textLength if (textLength > 0) { val logicalPosition = editor.offsetToLogicalPosition(textLength - 1) @@ -223,4 +168,4 @@ class ResponseEditorPanel( ) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/RetryListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/RetryListener.kt new file mode 100644 index 00000000..b0f54329 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/RetryListener.kt @@ -0,0 +1,92 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DiffHeaderPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.state.EditorStateManager +import ee.carlrobert.codegpt.toolwindow.chat.parser.* +import ee.carlrobert.llm.client.openai.completion.ErrorDetails +import ee.carlrobert.llm.completion.CompletionEventListener +import okhttp3.sse.EventSource + +class RetryListener( + private val project: Project, + private val messageParser: SseMessageParser, + private val stateManager: EditorStateManager, + private val onEditorReplaced: (EditorEx) -> Unit +) : CompletionEventListener { + + private val logger = logger() + private var editorReplaced: Boolean = false + + override fun onOpen() { + CompletionProgressNotifier.update(project, true) + } + + override fun onMessage(message: String, eventSource: EventSource?) { + processMessageSegments(message, eventSource) + } + + override fun onError(error: ErrorDetails?, ex: Throwable?) { + logger.error("Something went wrong while retrying diff-based editing", ex) + handleComplete() + } + + override fun onCancelled(messageBuilder: java.lang.StringBuilder?) { + handleComplete() + } + + override fun onComplete(messageBuilder: StringBuilder?) { + handleComplete() + } + + private fun processMessageSegments( + message: String, + eventSource: EventSource? + ) { + val segments = messageParser.parse(message) + for (segment in segments) { + when (segment) { + is SearchReplace -> { + stateManager.getCurrentState()?.updateContent(segment) + eventSource?.cancel() + return + } + + is SearchWaiting -> {} + + is ReplaceWaiting -> { + if (!editorReplaced) { + editorReplaced = true + + val newState = stateManager.createFromSegment(segment) + onEditorReplaced(newState.editor) + } + handleReplace(segment) + } + + is CodeEnd -> { + eventSource?.cancel() + } + + else -> return + } + } + } + + private fun handleReplace(item: ReplaceWaiting) { + val currentState = stateManager.getCurrentState() ?: return + val editor = currentState.editor + (editor.permanentHeaderComponent as? DiffHeaderPanel)?.editing() + + currentState.updateContent(item) + } + + private fun handleComplete() { + val editor = stateManager.getCurrentState()?.editor ?: return + (editor.permanentHeaderComponent as? DiffHeaderPanel)?.handleDone() + CompletionProgressNotifier.update(project, false) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/SearchReplaceHandler.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/SearchReplaceHandler.kt new file mode 100644 index 00000000..3aacb56e --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/SearchReplaceHandler.kt @@ -0,0 +1,100 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorKind +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.vfs.readText +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings +import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DiffHeaderPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.state.EditorStateManager +import ee.carlrobert.codegpt.toolwindow.chat.editor.state.FailedDiffEditorState +import ee.carlrobert.codegpt.toolwindow.chat.parser.Code +import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment + +class SearchReplaceHandler( + private val stateManager: EditorStateManager, + private val onEditorReplaced: (EditorEx, EditorEx) -> Unit +) { + private var searchFailed = false + + fun handleSearchReplace(item: SearchReplace, partialResponse: Boolean) { + val editor = stateManager.getCurrentState()?.editor ?: return + (editor.permanentHeaderComponent as? DiffHeaderPanel)?.handleDone() + + RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY.set(editor, Pair(item.search, item.replace)) + handleReplace(item, item.filePath, item.search, item.replace) + + val retryAllowed = + service().state.chatCompletionSettings.retryOnFailedDiffSearchEnabled + if (retryAllowed && stateManager.getCurrentState() is FailedDiffEditorState && partialResponse) { + stateManager.handleRetryForFailedSearch(item.replace) + } + } + + fun handleReplace(item: ReplaceWaiting) { + val editor = stateManager.getCurrentState()?.editor ?: return + (editor.permanentHeaderComponent as? DiffHeaderPanel)?.editing() + + handleReplace(item, item.filePath, item.search, item.replace) + } + + private fun handleReplace( + item: Segment, + filePath: String?, + searchContent: String, + replaceContent: String + ) { + val editor = stateManager.getCurrentState()?.editor ?: return + + if (filePath == null && editor.editorKind != EditorKind.DIFF) return + + val virtualFile = CodeGPTKeys.TOOLWINDOW_EDITOR_FILE_DETAILS.get(editor)?.virtualFile + if (virtualFile == null) { + if (searchFailed && editor.editorKind == EditorKind.UNTYPED && replaceContent.isNotEmpty()) { + stateManager.getCurrentState()?.updateContent(item) + } else { + handleNonExistentFile(replaceContent) + } + return + } + + val currentText = virtualFile.readText() + val containsText = currentText.contains(searchContent.trim()) + + if (searchContent.isNotEmpty() && editor.editorKind == EditorKind.DIFF && !containsText && !searchFailed) { + searchFailed = true + handleFailedDiffSearch(searchContent, replaceContent) + return + } + + stateManager.getCurrentState()?.updateContent(item) + } + + private fun handleNonExistentFile(replaceContent: String) { + val state = stateManager.getCurrentState() ?: return + val oldEditor = state.editor + val segment = Code(replaceContent, state.segment.language, state.segment.filePath) + + val newState = stateManager.createFromSegment(segment) + val newEditor = newState.editor + + onEditorReplaced(oldEditor, newEditor) + searchFailed = true + } + + private fun handleFailedDiffSearch(searchContent: String, replaceContent: String) { + val oldEditor = stateManager.getCurrentState()?.editor ?: return + val newState = stateManager.transitionToFailedDiffState(searchContent, replaceContent) + if (newState != null) { + val newEditor = newState.editor + runInEdt { + onEditorReplaced(oldEditor, newEditor) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ToolWindowEditorFileDetails.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ToolWindowEditorFileDetails.kt index cf71ac65..7de792f4 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ToolWindowEditorFileDetails.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ToolWindowEditorFileDetails.kt @@ -2,5 +2,4 @@ package ee.carlrobert.codegpt.toolwindow.chat.editor import com.intellij.openapi.vfs.VirtualFile -data class ToolWindowEditorFileDetails(val path: String, val virtualFile: VirtualFile? = null) { -} \ No newline at end of file +data class ToolWindowEditorFileDetails(val path: String, val virtualFile: VirtualFile? = null) \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt index 4d49458c..3e185e9b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt @@ -1,369 +1,73 @@ package ee.carlrobert.codegpt.toolwindow.chat.editor.actions -import com.intellij.diff.DiffManager -import com.intellij.diff.chains.SimpleDiffRequestChain -import com.intellij.diff.editor.ChainDiffVirtualFile -import com.intellij.diff.util.DiffUserDataKeys import com.intellij.icons.AllIcons -import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Presentation import com.intellij.openapi.actionSystem.ex.CustomComponentAction -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.command.WriteCommandAction -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.Messages -import com.intellij.openapi.util.Key -import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.readText -import com.intellij.testFramework.LightVirtualFile -import com.intellij.ui.JBColor -import com.intellij.ui.components.ActionLink -import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.AnActionLink import com.intellij.util.ui.JBUI -import ee.carlrobert.codegpt.CodeGPTBundle -import ee.carlrobert.codegpt.CodeGPTKeys -import ee.carlrobert.codegpt.Icons -import ee.carlrobert.codegpt.actions.ActionType -import ee.carlrobert.codegpt.actions.TrackableAction -import ee.carlrobert.codegpt.completions.CompletionClientProvider -import ee.carlrobert.codegpt.settings.GeneralSettings -import ee.carlrobert.codegpt.settings.service.ServiceType -import ee.carlrobert.codegpt.toolwindow.chat.editor.HeaderPanel -import ee.carlrobert.codegpt.toolwindow.chat.editor.ToolWindowEditorFileDetails -import ee.carlrobert.codegpt.ui.OverlayUtil -import ee.carlrobert.codegpt.util.EditorDiffUtil.createDiffRequest -import ee.carlrobert.llm.client.codegpt.request.AutoApplyRequest -import ee.carlrobert.llm.client.codegpt.response.CodeGPTException -import java.awt.FlowLayout -import java.io.File -import java.util.* -import javax.swing.Icon -import javax.swing.JButton +import ee.carlrobert.codegpt.util.EditorUtil import javax.swing.JComponent -import javax.swing.JPanel class AutoApplyAction( private val project: Project, - private val toolwindowEditor: Editor, - private val headerPanel: HeaderPanel, -) : TrackableAction( - CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.title"), - CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.description"), - Icons.Lightning, - ActionType.AUTO_APPLY -) { - private lateinit var diffRequestId: UUID - private var linksPanel: JPanel? = null + private val toolwindowEditor: EditorEx, + private val filePath: String?, + private val virtualFile: VirtualFile?, + private val onApply: () -> Unit, +) : CustomComponentAction, AnAction() { - companion object { - private val DIFF_REQUEST_KEY = Key.create("codegpt.autoApply.diffRequest") + private val anActionLink: AnActionLink = AnActionLink("Apply", this).apply { + icon = AllIcons.Actions.Execute + border = JBUI.Borders.empty(0, 4) + } + + override fun actionPerformed(e: AnActionEvent) { + onApply() } override fun update(e: AnActionEvent) { - if (GeneralSettings.getSelectedService() != ServiceType.CODEGPT) { - e.presentation.disableAction(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.disabledTitle")) - return - } - } - - private fun handleApply(request: AutoApplyRequest, editorVirtualFile: VirtualFile) { - val acceptLink = - createDisabledActionLink(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.accept")) - val rejectLink = - createDisabledActionLink(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject")) - - val newLinksPanel = JPanel(FlowLayout(FlowLayout.TRAILING, 8, 0)).apply { - isOpaque = false - border = JBUI.Borders.empty(4, 0) - add(acceptLink) - add(JBLabel("|")) - add(rejectLink) - } - - linksPanel = newLinksPanel - headerPanel.setRightComponent(newLinksPanel) - - ProgressManager.getInstance().run( - ApplyChangesBackgroundTask( - project, - request, - { modifiedFileContent -> - acceptLink.isEnabled = true - acceptLink.addActionListener { - WriteCommandAction.runWriteCommandAction(project) { - editorVirtualFile.setBinaryContent( - modifiedFileContent.toByteArray(editorVirtualFile.charset) - ) - } - resetState(editorVirtualFile) - } - - rejectLink.isEnabled = true - rejectLink.addActionListener { - resetState(editorVirtualFile) - } - - showDiff(editorVirtualFile, modifiedFileContent) - }, - { - val errorMessage = if (it is CodeGPTException) { - it.detail - } else { - CodeGPTBundle.get( - "toolwindow.chat.editor.action.autoApply.error", - it.message - ) - } - OverlayUtil.showNotification(errorMessage, NotificationType.ERROR) - runInEdt { - resetState(editorVirtualFile) - } - }) - ) - } - - override fun handleAction(event: AnActionEvent) { - val fileDetails = CodeGPTKeys.TOOLWINDOW_EDITOR_FILE_DETAILS.get(toolwindowEditor) - ?: return - - if (fileDetails.virtualFile == null || fileDetails.virtualFile.isDirectory) { - showAdditionalOptionsDialog(fileDetails, toolwindowEditor.document.text) + if (virtualFile == null && filePath != null) { + anActionLink.isEnabled = false + anActionLink.isVisible = false + anActionLink.toolTipText = "No file created" return } - val editorVirtualFile = fileDetails.virtualFile + if (virtualFile != null) { + anActionLink.text = "Apply" + anActionLink.isEnabled = true + anActionLink.toolTipText = "Apply changes to ${virtualFile.name}" - val request = AutoApplyRequest().apply { - suggestedChanges = toolwindowEditor.document.text - fileContent = editorVirtualFile.readText() + if (virtualFile.readText().trim() == toolwindowEditor.document.text.trim()) { + anActionLink.isEnabled = false + anActionLink.isVisible = true + anActionLink.toolTipText = "No changes to apply" + } + return } - handleApply(request, editorVirtualFile) + val selectedEditor = EditorUtil.getSelectedEditor(project) + anActionLink.text = if (selectedEditor?.virtualFile == null) { + "Apply" + } else { + "Apply to ${selectedEditor.virtualFile.name}" + } + anActionLink.isEnabled = selectedEditor != null + anActionLink.isVisible = true + } + + override fun createCustomComponent(presentation: Presentation, place: String): JComponent { + return anActionLink } override fun getActionUpdateThread(): ActionUpdateThread { return ActionUpdateThread.EDT } - - private fun Presentation.disableAction(disabledText: String? = null) { - isEnabled = false - icon = Icons.LightningDisabled - text = disabledText - } - - private fun showDiff(virtualFile: VirtualFile, modifiedFileContent: String) { - diffRequestId = UUID.randomUUID() - - val tempDiffFile = LightVirtualFile(virtualFile.name, modifiedFileContent) - val diffRequest = createDiffRequest(project, tempDiffFile, virtualFile).apply { - putUserData(DIFF_REQUEST_KEY, diffRequestId.toString()) - - val acceptAction = createContextActionButton( - CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.accept"), - Icons.GreenCheckmark, - JBColor(0x2E7D32, 0x4CAF50) - ) { - WriteCommandAction.runWriteCommandAction(project) { - virtualFile.setBinaryContent(modifiedFileContent.toByteArray(virtualFile.charset)) - } - resetState(virtualFile) - } - - val rejectAction = createContextActionButton( - CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject"), - AllIcons.Actions.Close, - JBColor(0xB71C1C, 0xF44336) - ) { - resetState(virtualFile) - } - - putUserData(DiffUserDataKeys.CONTEXT_ACTIONS, listOf(acceptAction, rejectAction)) - } - - runInEdt { - service().showDiff(project, diffRequest) - } - } - - private fun resetState(virtualFile: VirtualFile) { - headerPanel.restoreActionToolbar() - linksPanel = null - - val fileEditorManager = FileEditorManager.getInstance(project) - fileEditorManager.openFile(virtualFile, true) - - val diffFile = fileEditorManager.openFiles.firstOrNull { - it is ChainDiffVirtualFile && it.chain.requests - .filterIsInstance() - .any { chainRequest -> - chainRequest.request.getUserData(DIFF_REQUEST_KEY) == diffRequestId.toString() - } - } - if (diffFile != null) { - fileEditorManager.closeFile(diffFile) - } - } - - private fun createDisabledActionLink(text: String): ActionLink { - return ActionLink(text).apply { - isEnabled = false - autoHideOnDisable = false - } - } - - private fun createContextActionButton( - text: String, - icon: Icon, - textColor: JBColor, - onAction: (() -> Unit) - ): AnAction { - return object : AnAction(text, null, icon), CustomComponentAction { - override fun actionPerformed(e: AnActionEvent) { - onAction() - } - - override fun createCustomComponent( - presentation: Presentation, - place: String - ): JComponent { - val button = JButton(presentation.text).apply { - font = JBUI.Fonts.smallFont() - isFocusable = false - isOpaque = true - foreground = textColor - preferredSize = JBUI.size(preferredSize.width, 26) - maximumSize = JBUI.size(Int.MAX_VALUE, 26) - addActionListener { - onAction() - } - } - return button - } - } - } - - private fun createNewFile(filePath: String, content: String) { - - ProgressManager.getInstance().run(object : Task.Backgroundable( - project, - CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.creatingFile"), - true - ) { - override fun run(indicator: ProgressIndicator) { - try { - val file = File(filePath) - file.parentFile?.mkdirs() - - WriteCommandAction.runWriteCommandAction(project) { - file.writeText(content) - - val virtualFile = - LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) - - if (virtualFile != null) { - runInEdt { - FileEditorManager.getInstance(project).openFile(virtualFile, true) - } - } - } - } catch (ex: Exception) { - runInEdt { - OverlayUtil.showNotification( - CodeGPTBundle.get( - "toolwindow.chat.editor.action.autoApply.fileCreationError", - ex.message ?: "" - ), - NotificationType.ERROR - ) - } - } - } - }) - } - - private fun applyToExistingFile(content: String) { - val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return - val request = AutoApplyRequest().apply { - suggestedChanges = content - fileContent = editor.virtualFile.readText() - } - - handleApply(request, editor.virtualFile) - } - - private fun showAdditionalOptionsDialog( - fileDetails: ToolWindowEditorFileDetails, - content: String - ) { - val actions = mutableListOf Unit>>() - val canCreateNewFile = - fileDetails.virtualFile == null || !fileDetails.virtualFile.isDirectory - - if (canCreateNewFile) { - actions.add(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.dialog.createNew") to { - createNewFile(fileDetails.path, content) - }) - } - actions.add(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.dialog.applyToOpenFile") to { - applyToExistingFile(content) - }) - actions.add(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.dialog.cancel") to {}) - - val optionTexts = actions.map { it.first }.toTypedArray() - val defaultOptionIndex = 0 - - val result = Messages.showDialog( - project, - CodeGPTBundle.get( - "toolwindow.chat.editor.action.autoApply.dialog.message", - fileDetails.path - ), - CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.dialog.title"), - optionTexts, - defaultOptionIndex, - Messages.getQuestionIcon() - ) - - if (result >= 0 && result < actions.size) { - actions[result].second.invoke() - } - } - -} - -internal class ApplyChangesBackgroundTask( - project: Project, - private val request: AutoApplyRequest, - private val onSuccess: (modifiedFileContent: String) -> Unit, - private val onFailure: (ex: Exception) -> Unit, -) : Task.Backgroundable( - project, - CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.taskTitle"), - true -) { - - override fun run(indicator: ProgressIndicator) { - indicator.isIndeterminate = false - indicator.fraction = 1.0 - indicator.text = CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.loadingMessage") - - try { - val modifiedFileContent = CompletionClientProvider.getCodeGPTClient() - .applySuggestedChanges(request) - .modifiedFileContent - onSuccess(modifiedFileContent) - } catch (ex: Exception) { - onFailure(ex) - } - } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffAcceptedPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffAcceptedPanel.kt new file mode 100644 index 00000000..1ff94ace --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffAcceptedPanel.kt @@ -0,0 +1,57 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.diff + +import com.intellij.diff.tools.fragmented.UnifiedDiffChange +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.InlineBanner +import com.intellij.ui.components.ActionLink +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.io.File +import javax.swing.BoxLayout +import javax.swing.JPanel + +class DiffAcceptedPanel( + project: Project, + changes: List, + filePath: String, + onViewDetails: () -> Unit, +) : InlineBanner() { + + init { + isOpaque = false + border = JBUI.Borders.empty(8) + + val name = File(filePath).name + val fileLink = createFileLink(project, filePath, name) + val statsPanel = DiffStatsComponent.createStatsPanel(changes) + + val contentPanel = BorderLayoutPanel().andTransparent() + .addToLeft(createLeftPanel(fileLink, statsPanel)) + .addToRight(ActionLink("View Details") { onViewDetails() }) + + add(contentPanel) + status = EditorNotificationPanel.Status.Success + showCloseButton(false) + } + + private fun createFileLink(project: Project, filePath: String, name: String): ActionLink { + return ActionLink(name) { + val vFile = LocalFileSystem.getInstance().findFileByPath(filePath) + if (vFile != null) { + FileEditorManager.getInstance(project).openFile(vFile, true) + } + } + } + + private fun createLeftPanel(fileLink: ActionLink, statsPanel: JPanel): JPanel { + return JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + isOpaque = false + add(fileLink) + add(statsPanel) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffEditorManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffEditorManager.kt new file mode 100644 index 00000000..83d34204 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffEditorManager.kt @@ -0,0 +1,94 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.diff + +import com.intellij.diff.tools.fragmented.UnifiedDiffChange +import com.intellij.diff.tools.fragmented.UnifiedDiffViewer +import com.intellij.diff.util.DiffUtil +import com.intellij.diff.util.Side +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.diff.DiffBundle +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.readText +import com.intellij.util.concurrency.annotations.RequiresEdt +import ee.carlrobert.codegpt.toolwindow.chat.editor.diff.DiffManagerUtil.replaceContent +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DiffHeaderPanel + +class DiffEditorManager( + private val project: Project, + private val diffViewer: UnifiedDiffViewer, + private val virtualFile: VirtualFile? +) { + + fun updateDiffContent(searchContent: String, replaceContent: String): Boolean { + val currentText = virtualFile?.readText() ?: return false + val document = diffViewer.getDocument(Side.RIGHT) + + runInEdt { + document.replaceContent( + project, + currentText.replaceFirst(searchContent.trim(), replaceContent.trim()) + ) + + diffViewer.rediff(true) + scrollToLastChange(diffViewer) + } + return true + } + + fun applyAllChanges(): List { + val document = diffViewer.getDocument(Side.LEFT) + DiffManagerUtil.ensureDocumentWritable(project, document) + + val allChanges = mutableListOf() + + while (true) { + val changes = diffViewer.diffChanges ?: break + if (changes.isEmpty()) break + + val change = changes.first() + + DiffUtil.executeWriteCommand( + document, + project, + DiffBundle.message("message.replace.change.command") + ) { + diffViewer.replaceChange(change, Side.RIGHT) + diffViewer.scheduleRediff() + } + diffViewer.rediff(true) + + allChanges.add(change) + } + + return allChanges + } + + private fun scrollToLastChange(viewer: UnifiedDiffViewer) { + val change = viewer.diffChanges?.lastOrNull() ?: return + viewer.editors.firstOrNull()?.scrollingModel?.scrollTo( + LogicalPosition(change.lineFragment.startLine2, 0), + ScrollType.CENTER + ) + } +} + +object DiffManagerUtil { + + @RequiresEdt + fun Document.replaceContent(project: Project, replaceContent: String) { + ensureDocumentWritable(project, this) + DiffUtil.executeWriteCommand(this, project, "Updating document") { + setText(replaceContent) + } + } + + fun ensureDocumentWritable(project: Project, document: Document) { + if (!document.isWritable) { + DiffUtil.makeWritable(project, document) + document.setReadOnly(false) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffStatsComponent.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffStatsComponent.kt new file mode 100644 index 00000000..48909523 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffStatsComponent.kt @@ -0,0 +1,55 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.diff + +import com.intellij.diff.tools.fragmented.UnifiedDiffChange +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBLabel +import java.awt.Color +import java.awt.FlowLayout +import javax.swing.JPanel + +object DiffColors { + val INSERTED = Color(0x388E3C) + val DELETED = Color(0xD32F2F) + val MODIFIED = Color(0xFBC02D) +} + +class DiffStatsComponent { + companion object { + fun createStatsPanel(changes: List): JPanel { + val (inserted, deleted, modified) = DiffUtil.calculateDiffStats(changes) + + return JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + isOpaque = false + if (inserted > 0) add(JBLabel("+$inserted").apply { + foreground = DiffColors.INSERTED + }) + if (deleted > 0) add(JBLabel("-$deleted").apply { + foreground = DiffColors.DELETED + }) + if (modified > 0) add(JBLabel("~$modified").apply { + foreground = DiffColors.MODIFIED + }) + } + } + + fun updateStatsComponent(component: SimpleColoredComponent, changes: List) { + val (inserted, deleted, modified) = DiffUtil.calculateDiffStats(changes) + + component.clear() + val stats = buildList { + if (inserted > 0) add("+$inserted" to DiffColors.INSERTED) + if (deleted > 0) add("-$deleted" to DiffColors.DELETED) + if (modified > 0) add("~$modified" to DiffColors.MODIFIED) + } + + stats.forEachIndexed { idx, (text, color) -> + component.append( + text, + SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, color) + ) + if (idx < stats.lastIndex) component.append(" ") + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffSyncManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffSyncManager.kt new file mode 100644 index 00000000..732eb7b8 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffSyncManager.kt @@ -0,0 +1,89 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.diff + +import com.intellij.diff.util.Side +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runUndoTransparentWriteAction +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.util.application +import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_DIFF_VIEWER_KEY +import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY +import java.util.concurrent.ConcurrentHashMap + +object DiffSyncManager { + + private val fileToEditors = ConcurrentHashMap>() + private val fileToListener = ConcurrentHashMap() + + fun registerEditor(filePath: String, editor: EditorEx) { + fileToEditors.compute(filePath) { _, set -> + (set ?: mutableSetOf()).apply { add(editor) } + } + + if (!fileToListener.containsKey(filePath)) { + val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath) ?: return + val document = FileDocumentManager.getInstance().getDocument(virtualFile) ?: return + + val listener = object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + application.executeOnPooledThread { + val affectedEditors = fileToEditors[filePath] ?: emptyList() + for (editor in affectedEditors) { + val diffViewer = RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor) + if (diffViewer != null) { + val leftSideDoc = diffViewer.getDocument(Side.LEFT) + val rightSideDoc = diffViewer.getDocument(Side.RIGHT) + + if (leftSideDoc.text == rightSideDoc.text) { + continue + } + + val entry = RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY.get(editor) + if (entry != null) { + val (search, replace) = entry + val newText = event.document.text + if (!newText.contains(replace.trim())) { + val replacedText = + newText.replace(search.trim(), replace.trim()) + runInEdt { + if (replacedText.length != newText.length) { + runUndoTransparentWriteAction { + rightSideDoc.setText(replacedText) + diffViewer.scheduleRediff() + } + } + diffViewer.rediff(true) + } + } + } + } + } + } + } + } + + document.addDocumentListener(listener) + fileToListener[filePath] = listener + } + } + + fun unregisterEditor(filePath: String, editor: EditorEx) { + fileToEditors[filePath]?.let { set -> + set.remove(editor) + if (set.isEmpty()) { + fileToEditors.remove(filePath) + + val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath) + val document = + virtualFile?.let { FileDocumentManager.getInstance().getDocument(it) } + val listener = fileToListener.remove(filePath) + if (document != null && listener != null) { + document.removeDocumentListener(listener) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffUtil.kt new file mode 100644 index 00000000..763b6ca7 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffUtil.kt @@ -0,0 +1,17 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.diff + +import com.intellij.diff.tools.fragmented.UnifiedDiffChange + +object DiffUtil { + + fun calculateDiffStats(changes: List): Triple = + changes.fold(Triple(0, 0, 0)) { (ins, del, mod), change -> + val deletedLines = change.lineFragment.endLine1 - change.lineFragment.startLine1 + val insertedLines = change.lineFragment.endLine2 - change.lineFragment.startLine2 + val minLines = minOf(deletedLines, insertedLines) + val newMod = if (deletedLines > 0 && insertedLines > 0) mod + minLines else mod + val newDel = del + (deletedLines - minLines) + val newIns = ins + (insertedLines - minLines) + Triple(newIns, newDel, newMod) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/factory/ComponentFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/factory/ComponentFactory.kt new file mode 100644 index 00000000..d0ca0061 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/factory/ComponentFactory.kt @@ -0,0 +1,107 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.factory + +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.util.Key +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.ui.components.ActionLink +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.Icons +import ee.carlrobert.codegpt.actions.toolwindow.ReplaceCodeInMainEditorAction +import java.awt.Dimension +import javax.swing.JPanel +import javax.swing.SwingConstants + +object ComponentFactory { + + val EXPANDED_KEY = Key.create("toolwindow.editor.isExpanded") + const val MAX_VISIBLE_LINES = 10 + const val MIN_LINES_FOR_EXPAND = 8 + + fun createExpandLinkPanel(editor: EditorEx): BorderLayoutPanel { + return BorderLayoutPanel().apply { + isOpaque = false + border = JBUI.Borders.compound( + JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 0, 1, 1, 1), + JBUI.Borders.empty(4) + ) + addToCenter(createExpandLink(editor)) + putClientProperty("proxyai.expandedLinkPanel", true) + } + } + + fun createEditorActionGroup(editor: Editor): DefaultActionGroup { + return DefaultActionGroup().apply { + add(ReplaceCodeInMainEditorAction()) + (editor as? EditorEx)?.contextMenuGroupId?.let { groupId -> + val actionManager = ActionManager.getInstance() + val originalGroup = actionManager.getAction(groupId) + if (originalGroup is ActionGroup) { + addAll(originalGroup.getChildren(null, actionManager).toList()) + } + } + } + } + + fun updateEditorPreferredSize(editor: EditorEx, expanded: Boolean) { + val lineHeight = editor.lineHeight + val lineCount = editor.document.lineCount + + if (lineCount <= MIN_LINES_FOR_EXPAND) { + return + } + + if (editor.isOneLineMode) { + editor.component.preferredSize = + Dimension(editor.component.width, editor.component.height) + } else { + if (expanded) { + editor.component.preferredSize = null + } else { + val visibleLines = lineCount.coerceAtMost(MAX_VISIBLE_LINES) + val desiredHeight = (lineHeight * visibleLines).coerceAtLeast(20) + + editor.component.preferredSize = Dimension(editor.component.width, desiredHeight) + } + } + + editor.component.revalidate() + editor.component.repaint() + } + + private fun createExpandLink(editor: EditorEx): ActionLink { + val isExpanded = EXPANDED_KEY.get(editor) ?: false + val linkText = getLinkText(isExpanded) + + return ActionLink(linkText) { event -> + val currentState = EXPANDED_KEY.get(editor) ?: false + val newState = !currentState + EXPANDED_KEY.set(editor, newState) + + val source = event.source as ActionLink + source.text = getLinkText(newState) + source.icon = if (newState) Icons.CollapseAll else Icons.ExpandAll + + updateEditorPreferredSize(editor, newState) + }.apply { + icon = if (isExpanded) Icons.CollapseAll else Icons.ExpandAll + font = JBUI.Fonts.smallFont() + foreground = JBColor.GRAY + horizontalAlignment = SwingConstants.CENTER + } + } + + private fun getLinkText(expanded: Boolean): String { + return if (expanded) { + CodeGPTBundle.get("toolwindow.chat.editor.action.collapse") + } else { + CodeGPTBundle.get("toolwindow.chat.editor.action.expand") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/factory/EditorFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/factory/EditorFactory.kt new file mode 100644 index 00000000..04f43882 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/factory/EditorFactory.kt @@ -0,0 +1,115 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.factory + +import com.intellij.diff.DiffContentFactory +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.tools.fragmented.UnifiedDiffViewer +import com.intellij.openapi.application.invokeAndWaitIfNeeded +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.EditorKind +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.impl.ContextMenuPopupHandler +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.readText +import com.intellij.ui.ColorUtil +import com.intellij.util.ui.JBUI +import com.intellij.vcsUtil.VcsUtil.getVirtualFile +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.predictions.CodeSuggestionDiffViewer.MyDiffContext +import ee.carlrobert.codegpt.toolwindow.chat.editor.diff.DiffSyncManager +import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.ToolWindowEditorFileDetails +import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchWaiting +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment +import ee.carlrobert.codegpt.util.EditorUtil +import ee.carlrobert.codegpt.util.file.FileUtil +import javax.swing.JComponent + +object EditorFactory { + + fun createEditor( + project: Project, + segment: Segment, + readOnly: Boolean, + ): EditorEx { + val content = segment.content + val languageMapping = FileUtil.findLanguageExtensionMapping(segment.language) + val isDiffType = isDiffType(segment, content) + + return invokeAndWaitIfNeeded { + val editor = if (isDiffType) { + createDiffEditor(project, segment) + ?: EditorUtil.createEditor(project, languageMapping.value, content) + } else { + EditorUtil.createEditor(project, languageMapping.value, content) + } as EditorEx + segment.filePath?.let { filePath -> + CodeGPTKeys.TOOLWINDOW_EDITOR_FILE_DETAILS.set( + editor, + ToolWindowEditorFileDetails(filePath, getVirtualFile(filePath)) + ) + DiffSyncManager.registerEditor(filePath, editor) + + } + editor + } + } + + fun configureEditor(editor: EditorEx, headerComponent: JComponent? = null) { + editor.permanentHeaderComponent = headerComponent + editor.headerComponent = null + + editor.settings.apply { + additionalColumnsCount = 0 + additionalLinesCount = 0 + isAdditionalPageAtBottom = false + isVirtualSpace = false + isUseSoftWraps = false + isLineNumbersShown = false + isLineMarkerAreaShown = editor.editorKind == EditorKind.DIFF + } + editor.gutterComponentEx.apply { + isVisible = editor.editorKind == EditorKind.DIFF + parent.isVisible = editor.editorKind == EditorKind.DIFF + } + + editor.contentComponent.border = JBUI.Borders.emptyLeft(4) + editor.setBorder(JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"))) + editor.installPopupHandler( + ContextMenuPopupHandler.Simple( + ComponentFactory.createEditorActionGroup(editor) + ) + ) + } + + private fun createDiffEditor(project: Project, segment: Segment): EditorEx? { + val filePath = segment.filePath ?: return null + val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath) + ?: return null + val leftContent = DiffContentFactory.getInstance().create(project, virtualFile) + + val rightContentDoc = EditorFactory.getInstance().createDocument(virtualFile.readText()) + rightContentDoc.setReadOnly(false) + + val rightContent = + DiffContentFactory.getInstance().create(project, rightContentDoc, virtualFile) + val diffRequest = SimpleDiffRequest( + "Code Diff", + listOf(leftContent, rightContent), + listOf("Original", "Modified") + ) + + val diffViewer = UnifiedDiffViewer(MyDiffContext(project), diffRequest) + ResponseEditorPanel.RESPONSE_EDITOR_DIFF_VIEWER_KEY.set(diffViewer.editor, diffViewer) + return diffViewer.editor + } + + private fun isDiffType(segment: Segment, content: String): Boolean { + return segment is ReplaceWaiting + || segment is SearchWaiting + || segment is SearchReplace + || content.startsWith("<<<") + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/DefaultHeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/DefaultHeaderPanel.kt new file mode 100644 index 00000000..3870c850 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/DefaultHeaderPanel.kt @@ -0,0 +1,108 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.header + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.actionSystem.toolbarLayout.ToolbarLayoutStrategy +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.JBMenuItem +import com.intellij.openapi.ui.JBPopupMenu +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.* +import ee.carlrobert.codegpt.toolwindow.chat.editor.state.EditorStateManager +import ee.carlrobert.codegpt.util.EditorUtil +import javax.swing.JPanel + +class DefaultHeaderPanel(config: HeaderConfig) : HeaderPanel(config) { + + private val loadingLabel: JBLabel by lazy { + JBLabel( + CodeGPTBundle.get("toolwindow.chat.editor.diff.thinking"), + AnimatedIcon.Default(), + JBLabel.LEFT + ) + } + + init { + setupUI() + } + + override fun initializeRightPanel(rightPanel: JPanel) { + if (config.loading) { + rightPanel.add(loadingLabel) + } else { + rightPanel.add(createHeaderActions().component) + } + } + + fun setLoading() { + setRightPanelComponent(loadingLabel) + } + + fun handleDone() { + setRightPanelComponent(createHeaderActions().component) + } + + private fun createHeaderActions(): ActionToolbar { + val editor = config.editorEx + val project = config.project + val actionGroup = DefaultActionGroup("EDITOR_TOOLBAR_ACTION_GROUP", false) + if (config.readOnly) { + actionGroup.add(CopyAction(editor)) + } else { + actionGroup.add(AutoApplyAction(project, editor, config.filePath, virtualFile) { + handleApply(project, editor) + }) + actionGroup.add(CopyAction(editor)) + actionGroup.addSeparator() + actionGroup.add(createGearAction()) + } + return createToolbar(actionGroup) + } + + private fun handleApply(project: Project, editor: EditorEx) { + val file = virtualFile + ?: EditorUtil.getSelectedEditor(project)?.virtualFile + ?: throw IllegalStateException("Virtual file is null") + + setLoading() + project.service() + .getCodeEditsAsync(editor.document.text, file, editor) + } + + private fun createToolbar(actionGroup: ActionGroup): ActionToolbar { + val toolbar = ActionManager.getInstance() + .createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true) + toolbar.layoutStrategy = ToolbarLayoutStrategy.NOWRAP_STRATEGY + toolbar.targetComponent = this + toolbar.component.border = JBUI.Borders.empty() + toolbar.updateActionsAsync() + return toolbar + } + + private fun createGearActionsMenu(): JBPopupMenu { + val editor = config.editorEx + val menu = JBPopupMenu() + menu.add(JBMenuItem(DiffAction(editor, menu.location))) + menu.add(JBMenuItem(ReplaceSelectionAction(editor, menu.location))) + menu.add(JBMenuItem(InsertAtCaretAction(editor, menu.location))) + menu.add(JBMenuItem(EditAction(editor))) + menu.add(JBMenuItem(NewFileAction(editor, config.language))) + return menu + } + + private fun createGearAction(): AnAction { + return object : AnAction("Editor Actions", "Editor actions", AllIcons.General.GearPlain) { + override fun actionPerformed(e: AnActionEvent) { + val inputEvent = e.inputEvent + if (inputEvent != null) { + createGearActionsMenu().show(inputEvent.component, 0, 0) + } + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/DiffHeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/DiffHeaderPanel.kt new file mode 100644 index 00000000..9cde05f1 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/DiffHeaderPanel.kt @@ -0,0 +1,111 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.header + +import com.intellij.diff.tools.fragmented.UnifiedDiffChange +import com.intellij.openapi.application.runInEdt +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.JBColor +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.diff.DiffAcceptedPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.diff.DiffStatsComponent +import java.awt.BorderLayout +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JPanel + +interface DiffHeaderActions { + fun onAcceptAll() + fun onOpenDiff() +} + +class DiffHeaderPanel( + config: HeaderConfig, + retry: Boolean, + private val actions: DiffHeaderActions +) : HeaderPanel(config) { + + private val loadingLabel: JBLabel = JBLabel( + if (retry) CodeGPTBundle.get("toolwindow.chat.editor.diff.retrying") + else CodeGPTBundle.get("toolwindow.chat.editor.diff.reading"), + AnimatedIcon.Default(), + JBLabel.LEFT + ) + private val actionLinksPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + isVisible = false + add(ActionLink("View Diff") { actions.onOpenDiff() }) + add(Box.createHorizontalStrut(4)) + add(JBLabel("·").apply { + font = JBUI.Fonts.smallFont() + foreground = JBColor.GRAY + }) + add(Box.createHorizontalStrut(4)) + add(ActionLink(CodeGPTBundle.get("shared.acceptAll")) { actions.onAcceptAll() }) + } + private val statsComponent: SimpleColoredComponent = SimpleColoredComponent() + + init { + setupUI() + } + + override fun initializeRightPanel(rightPanel: JPanel) { + if (config.readOnly) return + + rightPanel.apply { + add(statsComponent) + add(separator()) + add(actionLinksPanel) + add(loadingLabel) + } + } + + fun handleDone() { + runInEdt { + actionLinksPanel.isVisible = true + loadingLabel.isVisible = false + revalidate() + repaint() + } + } + + fun handleChangesApplied( + patches: List + ) { + actionLinksPanel.isVisible = false + loadingLabel.isVisible = false + + val diffAcceptedPanel = DiffAcceptedPanel(config.project, patches, config.filePath!!) { } + runInEdt { + val container = config.editorEx.component.parent + if (container is ResponseEditorPanel) { + container.removeEditorAndAuxiliaryPanels() + container.add(diffAcceptedPanel, BorderLayout.CENTER) + container.revalidate() + container.repaint() + } else { + setRightPanelComponent(diffAcceptedPanel) + revalidate() + repaint() + } + } + } + + fun updateDiffStats(changes: List) { + runInEdt { + DiffStatsComponent.updateStatsComponent(statsComponent, changes) + revalidate() + repaint() + } + } + + fun editing() { + runInEdt { + loadingLabel.text = CodeGPTBundle.get("toolwindow.chat.editor.diff.editing") + loadingLabel.isVisible = true + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/HeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/HeaderPanel.kt new file mode 100644 index 00000000..eb477cca --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/HeaderPanel.kt @@ -0,0 +1,154 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.header + +import com.intellij.icons.AllIcons +import com.intellij.ide.actions.OpenFileAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.ui.SeparatorComponent +import com.intellij.ui.SeparatorOrientation +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.io.File +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel + +data class HeaderConfig( + val project: Project, + val editorEx: EditorEx, + val filePath: String?, + val language: String, + val readOnly: Boolean, + val loading: Boolean = false +) + +abstract class HeaderPanel(protected val config: HeaderConfig) : BorderLayoutPanel() { + + companion object { + private val logger = thisLogger() + } + + protected var virtualFile: VirtualFile? = config.filePath?.let { + try { + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(File(it)) + } catch (t: Throwable) { + logger.error(t) + null + } + } + + private val rightPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + isOpaque = false + } + + protected abstract fun initializeRightPanel(rightPanel: JPanel) + + protected fun setupUI() { + setupPanelAppearance() + setupFilePathOrLanguageLabel(virtualFile) + rightPanel.removeAll() + initializeRightPanel(rightPanel) + addToRight(rightPanel) + } + + protected fun setRightPanelComponent(component: JComponent?) { + if (component != null) { + rightPanel.removeAll() + rightPanel.add(component) + revalidate() + repaint() + } + } + + protected fun separator() = SeparatorComponent( + ColorUtil.fromHex("#48494b"), + SeparatorOrientation.VERTICAL + ).apply { + setVGap(4) + setHGap(6) + } + + private fun setupPanelAppearance() { + border = JBUI.Borders.compound( + JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 1, 1, 0, 1), + JBUI.Borders.empty(4) + ) + } + + protected fun setupFilePathOrLanguageLabel(virtualFile: VirtualFile?) { + val filePath = config.filePath + if (filePath != null) { + ApplicationManager.getApplication().executeOnPooledThread { + if (virtualFile == null) { + addComponent(createNewFileLink(filePath, config.editorEx)) + } else { + virtualFile.refresh(true, false) + addComponent(createFileLink(virtualFile)) + } + } + } else { + addComponent(createLanguageLabel()) + } + } + + private fun addComponent(component: JComponent) { + runInEdt { + addToLeft(component) + } + } + + private fun createNewFileLink(filePath: String, editor: EditorEx): ActionLink { + var actionLink: ActionLink? = null + actionLink = ActionLink("Add ${File(filePath).name}") { + val file = File(filePath) + val parent = file.parentFile + if (parent != null && !parent.exists()) { + parent.mkdirs() + } + + val content = editor.document.text + try { + val created = file.createNewFile() + if (!created) { + return@ActionLink + } + file.writeText(content) + } catch (ex: Exception) { + logger.error(ex) + return@ActionLink + } + + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)?.let { newFile -> + runInEdt { + OpenFileAction.openFile(newFile, config.project) + remove(actionLink) + setupFilePathOrLanguageLabel(newFile) + } + } + }.apply { icon = AllIcons.General.InlineAdd } + return actionLink + } + + private fun createFileLink(virtualFile: VirtualFile): ActionLink { + return ActionLink(virtualFile.name) { + OpenFileAction.openFile(virtualFile, config.project) + }.apply { setExternalLinkIcon() } + } + + private fun createLanguageLabel(): JBLabel { + return JBLabel(config.language).apply { + foreground = JBColor.GRAY + border = JBUI.Borders.emptyLeft(4) + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/DiffEditorState.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/DiffEditorState.kt new file mode 100644 index 00000000..72140199 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/DiffEditorState.kt @@ -0,0 +1,167 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.state + +import com.intellij.diff.DiffContentFactory +import com.intellij.diff.DiffManager +import com.intellij.diff.chains.SimpleDiffRequestChain +import com.intellij.diff.editor.ChainDiffVirtualFile +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.tools.fragmented.UnifiedDiffViewer +import com.intellij.diff.util.DiffUserDataKeys +import com.intellij.diff.util.Side +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.CustomComponentAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.Icons +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DiffHeaderActions +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DiffHeaderPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.HeaderConfig +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment +import ee.carlrobert.codegpt.util.file.FileUtil +import java.util.* +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JComponent + +abstract class DiffEditorState( + override val editor: EditorEx, + override val segment: Segment, + override val project: Project, + val diffViewer: UnifiedDiffViewer?, + val virtualFile: VirtualFile? +) : EditorState { + + companion object { + private val DIFF_REQUEST_KEY = Key.create("codegpt.autoApply.diffRequest") + } + + private lateinit var diffRequestId: UUID + + override fun createHeaderComponent(readOnly: Boolean): JComponent? { + val languageMapping = FileUtil.findLanguageExtensionMapping(segment.language) + val actions: DiffHeaderActions = object : DiffHeaderActions { + override fun onAcceptAll() { + applyAllChanges() + } + + override fun onOpenDiff() { + openDiff() + } + } + + return DiffHeaderPanel( + HeaderConfig( + project, + editor, + segment.filePath, + languageMapping.key, + false + ), + readOnly, + actions + ) + } + + abstract fun applyAllChanges() + + private fun openDiff() { + if (virtualFile == null) { + throw IllegalStateException("Virtual file is null") + } + + diffViewer?.let { viewer -> + diffRequestId = UUID.randomUUID() + + val diffContentFactory = DiffContentFactory.getInstance() + val leftSide = diffContentFactory.create(project, virtualFile) + val rightSideDoc = viewer.getDocument(Side.RIGHT).apply { setReadOnly(true) } + val rightSide = diffContentFactory.create(project, rightSideDoc, virtualFile) + var diffRequest = SimpleDiffRequest( + "Code Diff", + listOf(leftSide, rightSide), + listOf("Original", "Modified") + ).apply { + val acceptAction = createContextActionButton( + CodeGPTBundle.get("shared.acceptAll"), + Icons.GreenCheckmark, + JBColor(0x2E7D32, 0x4CAF50) + ) { + WriteCommandAction.runWriteCommandAction(project) { + applyAllChanges() + } + } + + val rejectAction = createContextActionButton( + CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject"), + AllIcons.Actions.Close, + JBColor(0xB71C1C, 0xF44336) + ) { + resetState(virtualFile) + } + + putUserData(DiffUserDataKeys.CONTEXT_ACTIONS, listOf(acceptAction, rejectAction)) + putUserData(DIFF_REQUEST_KEY, diffRequestId.toString()) + } + + service().showDiff(project, diffRequest) + } + } + + private fun resetState(virtualFile: VirtualFile) { + val fileEditorManager = FileEditorManager.getInstance(project) + fileEditorManager.openFile(virtualFile, true) + + val diffFile = fileEditorManager.openFiles.firstOrNull { + it is ChainDiffVirtualFile && it.chain.requests + .filterIsInstance() + .any { chainRequest -> + chainRequest.request.getUserData(DIFF_REQUEST_KEY) == diffRequestId.toString() + } + } + if (diffFile != null) { + fileEditorManager.closeFile(diffFile) + } + } + + private fun createContextActionButton( + text: String, + icon: Icon, + textColor: JBColor, + onAction: (() -> Unit) + ): AnAction { + return object : AnAction(text, null, icon), CustomComponentAction { + override fun actionPerformed(e: AnActionEvent) { + onAction() + } + + override fun createCustomComponent( + presentation: Presentation, + place: String + ): JComponent { + val button = JButton(presentation.text).apply { + font = JBUI.Fonts.smallFont() + isFocusable = false + isOpaque = true + foreground = textColor + preferredSize = JBUI.size(preferredSize.width, 26) + maximumSize = JBUI.size(Int.MAX_VALUE, 26) + addActionListener { + onAction() + } + } + return button + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorState.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorState.kt new file mode 100644 index 00000000..41471ebf --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorState.kt @@ -0,0 +1,15 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.state + +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment +import javax.swing.JComponent + +interface EditorState { + val editor: EditorEx + val segment: Segment + val project: Project + + fun updateContent(segment: Segment) + fun createHeaderComponent(readOnly: Boolean): JComponent? +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorStateManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorStateManager.kt new file mode 100644 index 00000000..3068632d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorStateManager.kt @@ -0,0 +1,112 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.state + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.EditorKind +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.readText +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.completions.AutoApplyParameters +import ee.carlrobert.codegpt.completions.CompletionRequestService +import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_STATE_KEY +import ee.carlrobert.codegpt.toolwindow.chat.editor.RetryListener +import ee.carlrobert.codegpt.toolwindow.chat.editor.diff.DiffEditorManager +import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.EditorFactory +import ee.carlrobert.codegpt.toolwindow.chat.parser.Code +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment +import ee.carlrobert.codegpt.toolwindow.chat.parser.SseMessageParser + +@Service(Service.Level.PROJECT) +class EditorStateManager(private val project: Project) { + + private var currentState: EditorState? = null + private var diffEditorManager: DiffEditorManager? = null + + fun createFromSegment(segment: Segment, readOnly: Boolean = false): EditorState { + val editor = EditorFactory.createEditor(project, segment, readOnly) + val state = if (editor.editorKind == EditorKind.DIFF) { + createDiffState(editor, segment) + } else { + RegularEditorState(editor, segment, project) + } + runInEdt { + EditorFactory.configureEditor(editor, state.createHeaderComponent(readOnly)) + } + + RESPONSE_EDITOR_STATE_KEY.set(editor, state) + currentState = state + return state + } + + fun handleRetryForFailedSearch(replaceContent: String) { + val editor = currentState?.editor ?: return + val virtualFile = + CodeGPTKeys.TOOLWINDOW_EDITOR_FILE_DETAILS.get(editor)?.virtualFile ?: return + + getCodeEditsAsync(replaceContent, virtualFile, editor) + } + + fun getCodeEditsAsync( + content: String, + virtualFile: VirtualFile, + editor: EditorEx, + ) { + val params = AutoApplyParameters(content, virtualFile) + val messageParser = SseMessageParser() + val listener = RetryListener(project, messageParser, this) { newEditor -> + val responseEditorPanel = editor.component.parent as? ResponseEditorPanel + ?: throw IllegalStateException("Expected parent to be ResponseEditorPanel") + responseEditorPanel.replaceEditor(editor, newEditor) + } + + CompletionRequestService.getInstance().getCodeEditsAsync(params, listener) + } + + fun transitionToFailedDiffState(searchContent: String, replaceContent: String): EditorState? { + val currentState = this.currentState ?: return null + + val segment = currentState.segment + val virtualFile = getVirtualFile(segment.filePath) ?: return null + + val newSegment = Code(replaceContent, virtualFile.extension ?: "Text", virtualFile.path) + val newEditor = EditorFactory.createEditor(project, newSegment, false) + + val newState = + FailedDiffEditorState(newEditor, newSegment, project, searchContent, replaceContent) + + runInEdt { + EditorFactory.configureEditor(newEditor, newState.createHeaderComponent(false)) + } + + this.currentState = newState + + return newState + } + + fun getCurrentState(): EditorState? { + return currentState + } + + private fun createDiffState(editor: EditorEx, segment: Segment): EditorState { + val virtualFile = getVirtualFile(segment.filePath) + val diffViewer = ResponseEditorPanel.RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor) + val diffEditorManager = DiffEditorManager(project, diffViewer, virtualFile) + this.diffEditorManager = diffEditorManager + return StandardDiffEditorState( + editor, + segment, + project, + diffViewer, + virtualFile, + diffEditorManager + ) + } + + private fun getVirtualFile(filePath: String?): VirtualFile? { + return filePath?.let { LocalFileSystem.getInstance().findFileByPath(it) } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/FailedDiffEditorState.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/FailedDiffEditorState.kt new file mode 100644 index 00000000..c92d3428 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/FailedDiffEditorState.kt @@ -0,0 +1,34 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.state + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DefaultHeaderPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.HeaderConfig +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment +import javax.swing.JComponent + +class FailedDiffEditorState( + override val editor: EditorEx, + override val segment: Segment, + override val project: Project, + private val searchContent: String, + private val replaceContent: String +) : EditorState { + + override fun updateContent(segment: Segment) { + runInEdt { + runWriteAction { + editor.document.setText(segment.content) + } + } + } + + override fun createHeaderComponent(readOnly: Boolean): JComponent? { + val filePath = segment.filePath + val extension = filePath?.substringAfterLast('.', "txt") ?: "txt" + + return DefaultHeaderPanel(HeaderConfig(project, editor, filePath, extension, false)) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/RegularEditorState.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/RegularEditorState.kt new file mode 100644 index 00000000..6dc1adf5 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/RegularEditorState.kt @@ -0,0 +1,44 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.state + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DefaultHeaderPanel +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.HeaderConfig +import ee.carlrobert.codegpt.toolwindow.chat.parser.Code +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment +import ee.carlrobert.codegpt.util.file.FileUtil +import javax.swing.JComponent + +class RegularEditorState( + override val editor: EditorEx, + override val segment: Segment, + override val project: Project +) : EditorState { + + override fun updateContent(segment: Segment) { + runInEdt { + runWriteAction { + editor.document.setText(segment.content) + } + } + } + + override fun createHeaderComponent(readOnly: Boolean): JComponent? { + val languageMapping = FileUtil.findLanguageExtensionMapping(segment.language) + return if (segment is Code) { + DefaultHeaderPanel( + HeaderConfig( + project, + editor, + segment.filePath, + languageMapping.key, + readOnly + ), + ) + } else { + null + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/StandardDiffEditorState.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/StandardDiffEditorState.kt new file mode 100644 index 00000000..155f31cb --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/StandardDiffEditorState.kt @@ -0,0 +1,55 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor.state + +import com.intellij.diff.tools.fragmented.UnifiedDiffViewer +import com.intellij.ide.actions.OpenFileAction +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.editor.EditorKind +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.application +import ee.carlrobert.codegpt.toolwindow.chat.editor.diff.DiffEditorManager +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.DiffHeaderPanel +import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace +import ee.carlrobert.codegpt.toolwindow.chat.parser.Segment + +class StandardDiffEditorState( + editor: EditorEx, + segment: Segment, + project: Project, + diffViewer: UnifiedDiffViewer?, + virtualFile: VirtualFile?, + private val diffEditorManager: DiffEditorManager +) : DiffEditorState(editor, segment, project, diffViewer, virtualFile) { + + override fun applyAllChanges() { + val changes = diffEditorManager.applyAllChanges() + if (changes.isNotEmpty()) { + (editor.permanentHeaderComponent as? DiffHeaderPanel)?.handleChangesApplied(changes) + virtualFile?.let { OpenFileAction.openFile(it, project) } + } + } + + override fun updateContent(segment: Segment) { + if (editor.editorKind == EditorKind.DIFF) { + if (segment is SearchReplace) { + diffEditorManager.updateDiffContent(segment.search, segment.replace) + (editor.permanentHeaderComponent as? DiffHeaderPanel) + ?.updateDiffStats(diffViewer?.diffChanges ?: emptyList()) + } else if (segment is ReplaceWaiting) { + diffEditorManager.updateDiffContent(segment.search, segment.replace) + (editor.permanentHeaderComponent as? DiffHeaderPanel) + ?.updateDiffStats(diffViewer?.diffChanges ?: emptyList()) + } + } + } + + fun refresh() { + application.executeOnPooledThread { + runInEdt { + diffViewer?.rediff(true) + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteMessageParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteMessageParser.kt new file mode 100644 index 00000000..7659be2f --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteMessageParser.kt @@ -0,0 +1,239 @@ +package ee.carlrobert.codegpt.toolwindow.chat.parser + +import java.util.regex.Matcher +import java.util.regex.Pattern + +class CompleteMessageParser : MessageParser { + + companion object { + private val CODE_BLOCK_PATTERN: Pattern = + Pattern.compile("```([a-zA-Z0-9_+-]*)(?::([^\\n]*))?\\n(.*?)```", Pattern.DOTALL) + private val SEARCH_REPLACE_PATTERN: Pattern = + Pattern.compile("<<<<<<< SEARCH\\n(.*?)\\n=======\\n(.*?)\\n>>>>>>> REPLACE", Pattern.DOTALL) + private val INCOMPLETE_SEARCH_REPLACE_PATTERN: Pattern = + Pattern.compile("<<<<<<< SEARCH\\n(.*?)(?:\\n=======\\n(.*?))?$", Pattern.DOTALL) + + private const val THINK_OPEN_TAG = "" + private const val THINK_CLOSE_TAG = "\n\n" + private const val LANGUAGE_GROUP_INDEX = 1 + private const val FILE_PATH_GROUP_INDEX = 2 + private const val CODE_CONTENT_GROUP_INDEX = 3 + private const val SEARCH_CONTENT_GROUP_INDEX = 1 + private const val REPLACE_CONTENT_GROUP_INDEX = 2 + } + + var extractedThought: String? = null + private set + + /** + * Parses a complete text output, extracts an optional initial thought block, + * and identifies code blocks and regular text in the remaining content. + * + * @param input The full text output to parse + * @return A list of parsed response segments (excluding the thought) + */ + override fun parse(input: String): List { + val normalizedInput = input.replace("\r", "") + val contentAfterThoughtExtraction = extractThoughtIfPresent(normalizedInput) + + return parseContentIntoSegments(contentAfterThoughtExtraction) + } + + /** + * Extracts thought content if present at the beginning of the input. + * Updates the extractedThought property and returns content without the thought block. + */ + private fun extractThoughtIfPresent(input: String): String { + extractedThought = null + + if (!input.startsWith(THINK_OPEN_TAG)) { + return input + } + + val closeTagIndex = input.indexOf(THINK_CLOSE_TAG) + return if (closeTagIndex != -1) { + val thoughtStartIndex = THINK_OPEN_TAG.length + extractedThought = input.substring(thoughtStartIndex, closeTagIndex).trim() + input.substring(closeTagIndex + THINK_CLOSE_TAG.length) + } else { + input + } + } + + /** + * Parses the content into segments, handling code blocks and text. + */ + private fun parseContentIntoSegments(content: String): List = buildList { + val codeBlockMatcher = CODE_BLOCK_PATTERN.matcher(content) + var lastProcessedIndex = 0 + + while (codeBlockMatcher.find()) { + addTextSegmentIfExists(content, lastProcessedIndex, codeBlockMatcher.start()) + addCodeBlockSegments(codeBlockMatcher) + lastProcessedIndex = codeBlockMatcher.end() + } + + addTextSegmentIfExists(content, lastProcessedIndex, content.length) + } + + /** + * Adds a text segment if there's content between the specified indices. + */ + private fun MutableList.addTextSegmentIfExists( + content: String, + startIndex: Int, + endIndex: Int + ) { + if (endIndex > startIndex) { + val textContent = content.substring(startIndex, endIndex) + if (textContent.isNotEmpty()) { + add(Text(textContent)) + } + } + } + + /** + * Processes a code block and adds all related segments. + */ + private fun MutableList.addCodeBlockSegments(codeBlockMatcher: Matcher) { + val language = codeBlockMatcher.group(LANGUAGE_GROUP_INDEX).orEmpty() + val filePath = codeBlockMatcher.group(FILE_PATH_GROUP_INDEX) + val codeContent = codeBlockMatcher.group(CODE_CONTENT_GROUP_INDEX).orEmpty() + + add(CodeHeader(language, filePath)) + processCodeContent(codeContent, language, filePath) + add(CodeEnd(codeContent)) + } + + /** + * Processes code content, handling search/replace patterns or regular code. + */ + private fun MutableList.processCodeContent( + codeContent: String, + language: String, + filePath: String? + ) { + val searchReplaceSegments = extractSearchReplaceSegments(codeContent, language, filePath) + + if (searchReplaceSegments.isNotEmpty()) { + addAll(searchReplaceSegments) + } else { + add(Code(codeContent, language, filePath)) + } + } + + /** + * Extracts search/replace segments from code content. + * Returns empty list if no search/replace patterns are found. + */ + private fun extractSearchReplaceSegments( + codeContent: String, + language: String, + filePath: String? + ): List = buildList { + val searchReplaceMatcher = SEARCH_REPLACE_PATTERN.matcher(codeContent) + var lastProcessedIndex = 0 + var foundSearchReplace = false + + while (searchReplaceMatcher.find()) { + foundSearchReplace = true + addCodeSegmentIfExists(codeContent, lastProcessedIndex, searchReplaceMatcher.start(), language, filePath) + addSearchReplaceSegment(searchReplaceMatcher, language, filePath) + lastProcessedIndex = searchReplaceMatcher.end() + } + + if (!foundSearchReplace) { + val incompleteMatch = findIncompleteSearchReplace(codeContent, language, filePath) + if (incompleteMatch != null) { + addAll(incompleteMatch.segments) + lastProcessedIndex = incompleteMatch.endIndex + foundSearchReplace = true + } + } + + if (foundSearchReplace) { + addCodeSegmentIfExists(codeContent, lastProcessedIndex, codeContent.length, language, filePath) + } + } + + /** + * Adds a code segment if there's content between the specified indices. + */ + private fun MutableList.addCodeSegmentIfExists( + codeContent: String, + startIndex: Int, + endIndex: Int, + language: String, + filePath: String? + ) { + if (endIndex > startIndex) { + val code = codeContent.substring(startIndex, endIndex) + if (code.trim().isNotEmpty()) { + add(Code(code, language, filePath)) + } + } + } + + /** + * Adds a search/replace segment from the matcher. + */ + private fun MutableList.addSearchReplaceSegment( + matcher: Matcher, + language: String, + filePath: String? + ) { + val searchContent = matcher.group(SEARCH_CONTENT_GROUP_INDEX).orEmpty() + val replaceContent = matcher.group(REPLACE_CONTENT_GROUP_INDEX).orEmpty() + + add(SearchReplace( + search = searchContent, + replace = replaceContent, + language = language, + filePath = filePath + )) + } + + /** + * Finds incomplete search/replace patterns and returns the segments and end index. + */ + private fun findIncompleteSearchReplace( + codeContent: String, + language: String, + filePath: String? + ): IncompleteSearchReplaceResult? { + val incompleteMatcher = INCOMPLETE_SEARCH_REPLACE_PATTERN.matcher(codeContent) + + return if (incompleteMatcher.find()) { + val segments = buildList { + if (incompleteMatcher.start() > 0) { + val codeBefore = codeContent.substring(0, incompleteMatcher.start()) + if (codeBefore.trim().isNotEmpty()) { + add(Code(codeBefore, language, filePath)) + } + } + + val searchContent = incompleteMatcher.group(SEARCH_CONTENT_GROUP_INDEX).orEmpty() + val replaceContent = incompleteMatcher.group(REPLACE_CONTENT_GROUP_INDEX).orEmpty() + + add(SearchReplace( + search = searchContent, + replace = replaceContent, + language = language, + filePath = filePath + )) + } + + IncompleteSearchReplaceResult(segments, incompleteMatcher.end()) + } else { + null + } + } + + /** + * Data class to hold the result of incomplete search/replace processing. + */ + private data class IncompleteSearchReplaceResult( + val segments: List, + val endIndex: Int + ) +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteOutputParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteOutputParser.kt deleted file mode 100644 index 3f79ae54..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteOutputParser.kt +++ /dev/null @@ -1,68 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat.parser - -import java.util.regex.Pattern - -class CompleteOutputParser { - - companion object { - private val CODE_BLOCK_PATTERN: Pattern = - Pattern.compile("```([a-zA-Z0-9_+-]*)(?::([^\\n]*))?\\n(.*?)```", Pattern.DOTALL) - private const val THINK_OPEN_TAG = "" - private const val THINK_CLOSE_TAG = "\n\n" - } - - var extractedThought: String? = null - private set - - /** - * Parses a complete text output, extracts an optional initial thought block, - * and identifies code blocks and regular text in the remaining content. - * - * @param completeOutput The full text output to parse - * @return A list of parsed response segments (excluding the thought) - */ - fun parse(completeOutput: String): List { - extractedThought = null - var contentToParse = completeOutput.replace("\r", "") - - if (contentToParse.startsWith(THINK_OPEN_TAG)) { - val closeTagIndex = contentToParse.indexOf(THINK_CLOSE_TAG) - if (closeTagIndex != -1) { - val startContent = THINK_OPEN_TAG.length - extractedThought = contentToParse.substring(startContent, closeTagIndex).trim() - contentToParse = contentToParse.substring(closeTagIndex + THINK_CLOSE_TAG.length) - } - } - - return buildList { - val matcher = CODE_BLOCK_PATTERN.matcher(contentToParse) - var lastEnd = 0 - - while (matcher.find()) { - if (matcher.start() > lastEnd) { - val textBefore = contentToParse.substring(lastEnd, matcher.start()) - if (textBefore.isNotEmpty()) { - add(StreamParseResponse.Text(textBefore)) - } - } - - val language = matcher.group(1) ?: "" - val filePath: String? = matcher.group(2) - val codeContent = matcher.group(3) ?: "" - - add(StreamParseResponse.CodeHeader(language, filePath)) - add(StreamParseResponse.CodeContent(codeContent, language, filePath)) - add(StreamParseResponse.CodeEnd(language, filePath)) - - lastEnd = matcher.end() - } - - if (lastEnd < contentToParse.length) { - val remainingText = contentToParse.substring(lastEnd) - if (remainingText.isNotEmpty()) { - add(StreamParseResponse.Text(remainingText)) - } - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/MessageParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/MessageParser.kt new file mode 100644 index 00000000..fa0cb321 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/MessageParser.kt @@ -0,0 +1,6 @@ +package ee.carlrobert.codegpt.toolwindow.chat.parser + +interface MessageParser { + + fun parse(input: String): List +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/Segment.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/Segment.kt new file mode 100644 index 00000000..62385545 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/Segment.kt @@ -0,0 +1,42 @@ +package ee.carlrobert.codegpt.toolwindow.chat.parser + +sealed class Segment( + open val content: String = "", + open val language: String = "", + open val filePath: String? = null +) + +data class Text(override val content: String) : Segment(content) +data class Thinking(override val content: String) : Segment(content) +data class CodeHeader( + override val language: String, + override val filePath: String? +) : Segment("", language, filePath) + +data class CodeHeaderWaiting(val partial: String) : Segment(partial) +data class Code( + override val content: String, + override val language: String, + override val filePath: String? +) : Segment(content, language, filePath) + +data class CodeEnd(override val content: String) : Segment(content) +data class SearchWaiting( + val search: String, + override val language: String, + override val filePath: String? +) : Segment(search, language, filePath) + +data class ReplaceWaiting( + val search: String, + val replace: String, + override val language: String, + override val filePath: String? +) : Segment(replace, language, filePath) + +data class SearchReplace( + val search: String, + val replace: String, + override val language: String, + override val filePath: String? +) : Segment(search, language, filePath) \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/SseMessageParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/SseMessageParser.kt new file mode 100644 index 00000000..5e351526 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/SseMessageParser.kt @@ -0,0 +1,239 @@ +package ee.carlrobert.codegpt.toolwindow.chat.parser + +enum class State { OUTSIDE, CODE_HEADER_WAITING, IN_CODE, IN_SEARCH, IN_REPLACE, IN_THINKING } + +class SseMessageParser : MessageParser { + + var state = State.OUTSIDE + private val buffer = StringBuilder() + private val parsedSegments = mutableListOf() + + private var currentCodeHeader: CodeHeader? = null + private val codeBuilder = StringBuilder() + private val headerBuilder = StringBuilder() + private val searchBuilder = StringBuilder() + private val replaceBuilder = StringBuilder() + private val thinkingBuilder = StringBuilder() + + fun clear() { + state = State.OUTSIDE + buffer.clear() + parsedSegments.clear() + currentCodeHeader = null + codeBuilder.clear() + headerBuilder.clear() + searchBuilder.clear() + replaceBuilder.clear() + thinkingBuilder.clear() + } + + /** + * Parse incoming partial text and return any completed segments. + * Leftover text remains in buffer until more input arrives. + */ + override fun parse(input: String): List { + buffer.append(input) + val output = mutableListOf() + + loop@ while (true) { + when (state) { + State.OUTSIDE -> { + val fenceIdx = buffer.indexOf("```") + val thinkStartIdx = buffer.indexOf("") + + when { + fenceIdx != -1 && (thinkStartIdx == -1 || fenceIdx < thinkStartIdx) -> { + if (fenceIdx > 0) { + output += Text(buffer.substring(0, fenceIdx)) + } + buffer.delete(0, fenceIdx + 3) + state = State.CODE_HEADER_WAITING + headerBuilder.clear() + continue@loop + } + + thinkStartIdx != -1 -> { + if (thinkStartIdx > 0) { + output += Text(buffer.substring(0, thinkStartIdx)) + } + buffer.delete(0, thinkStartIdx + "".length) + state = State.IN_THINKING + thinkingBuilder.clear() + continue@loop + } + + else -> break@loop + } + } + + State.CODE_HEADER_WAITING -> { + val nlIdx = buffer.indexOf("\n") + if (nlIdx < 0) break@loop + val headerLine = buffer.substring(0, nlIdx).trim() + buffer.delete(0, nlIdx + 1) + headerBuilder.append(headerLine) + + val headerText = headerBuilder.toString() + val parts = headerText.split(":", limit = 2) + + val language = parts.getOrNull(0) ?: "" + val fileName = parts.getOrNull(1) + + if (parts.size > 0) { + currentCodeHeader = CodeHeader(language, fileName) + output += currentCodeHeader!! + state = State.IN_CODE + codeBuilder.clear() + headerBuilder.clear() + } else { + output += CodeHeaderWaiting(headerText) + } + } + + State.IN_CODE -> { + val idx = buffer.indexOf("\n") + if (idx < 0) break@loop + val line = buffer.substring(0, idx) + buffer.delete(0, idx + 1) + when { + line.trim() == "```" -> { + if (codeBuilder.isNotEmpty()) { + output += Code( + codeBuilder.toString(), + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + } + output += CodeEnd("") + state = State.OUTSIDE + } + + line.trimStart().startsWith("<<<<<<< SEARCH") -> { + state = State.IN_SEARCH + searchBuilder.clear() + output += SearchWaiting( + "", + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + } + + else -> codeBuilder.appendLine(line) + } + } + + State.IN_SEARCH -> { + val idx = buffer.indexOf("\n") + if (idx < 0) break@loop + val line = buffer.substring(0, idx) + buffer.delete(0, idx + 1) + if (line.trim() == "=======") { + state = State.IN_REPLACE + replaceBuilder.clear() + output += ReplaceWaiting( + searchBuilder.toString(), + "", + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + } else { + searchBuilder.appendLine(line) + output += SearchWaiting( + searchBuilder.toString(), + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + } + } + + State.IN_REPLACE -> { + val idx = buffer.indexOf("\n") + if (idx < 0) break@loop + val line = buffer.substring(0, idx) + buffer.delete(0, idx + 1) + if (line.trim().startsWith(">>>>>>> REPLACE")) { + output += SearchReplace( + search = searchBuilder.toString(), + replace = replaceBuilder.toString(), + language = currentCodeHeader!!.language, + filePath = currentCodeHeader!!.filePath + ) + state = State.IN_CODE + } else { + replaceBuilder.appendLine(line) + output += ReplaceWaiting( + searchBuilder.toString(), + replaceBuilder.toString(), + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + } + } + + State.IN_THINKING -> { + val thinkEndIdx = buffer.indexOf("") + if (thinkEndIdx < 0) { + if (buffer.isNotEmpty()) { + thinkingBuilder.append(buffer) + output += Thinking(thinkingBuilder.toString()) + buffer.clear() + } + break@loop + } + + thinkingBuilder.append(buffer.substring(0, thinkEndIdx)) + output += Thinking(thinkingBuilder.toString()) + buffer.delete(0, thinkEndIdx + "".length) + state = State.OUTSIDE + thinkingBuilder.clear() + continue@loop + } + } + } + + when (state) { + State.OUTSIDE -> + if (buffer.isNotBlank()) + output += Text(buffer.toString()) + + State.CODE_HEADER_WAITING -> + if (headerBuilder.isNotBlank()) + output += CodeHeaderWaiting(headerBuilder.toString()) + + State.IN_CODE -> + if (codeBuilder.isNotBlank()) + output += Code( + codeBuilder.toString(), + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + + State.IN_SEARCH -> + if (searchBuilder.isNotBlank()) + output += SearchWaiting( + searchBuilder.toString(), + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + + State.IN_REPLACE -> + if (replaceBuilder.isNotBlank()) + output += ReplaceWaiting( + searchBuilder.toString(), + replaceBuilder.toString(), + currentCodeHeader!!.language, + currentCodeHeader!!.filePath + ) + + State.IN_THINKING -> + if (thinkingBuilder.isNotBlank() || buffer.isNotBlank()) { + thinkingBuilder.append(buffer) + buffer.clear() + output += Thinking(thinkingBuilder.toString()) + } + } + + parsedSegments.addAll(output) + return output + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/StreamOutputParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/StreamOutputParser.kt deleted file mode 100644 index 6e527c83..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/StreamOutputParser.kt +++ /dev/null @@ -1,195 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat.parser - -sealed class StreamParseResponse( - val type: StreamResponseType, - val content: String, - val language: String? = null, - val filePath: String? = null -) { - enum class StreamResponseType { - TEXT, THINKING, CODE_HEADER, CODE_CONTENT, CODE_END - } - - data class Text(val textContent: String) : - StreamParseResponse(StreamResponseType.TEXT, textContent) - - data class Thinking(val thoughtProcess: String) : - StreamParseResponse(StreamResponseType.THINKING, thoughtProcess) - - data class CodeHeader(val codeLanguage: String, val codeFilePath: String?) : - StreamParseResponse(StreamResponseType.CODE_HEADER, "", codeLanguage, codeFilePath) - - data class CodeContent( - val codeContent: String, - val codeLanguage: String, - val codeFilePath: String? - ) : - StreamParseResponse( - StreamResponseType.CODE_CONTENT, - codeContent, - codeLanguage, - codeFilePath - ) - - data class CodeEnd(val codeLanguage: String, val codeFilePath: String?) : - StreamParseResponse(StreamResponseType.CODE_END, "", codeLanguage, codeFilePath) -} - -class StreamOutputParser { - companion object { - private val CODE_BLOCK_PATTERN = Regex("```([a-zA-Z0-9_+-]*)(?::([^\\n]*))?\\n") - private const val THINK_START_TAG = "" - private const val THINK_END_TAG = "" - } - - private val messageBuilder = StringBuilder() - private var isProcessingCode = false - private var currentLanguage: String? = null - private var currentFilePath: String? = null - - private fun handleProcessText(matcher: MatchResult): List { - val responses = mutableListOf() - isProcessingCode = true - - val startingIndex = matcher.range.first - val prevMessage = messageBuilder.substring(0, startingIndex) - - currentLanguage = matcher.groupValues[1].takeIf { it.isNotEmpty() } ?: "" - currentFilePath = matcher.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } - - messageBuilder.delete(0, startingIndex + matcher.value.length) - - if (prevMessage.isNotEmpty()) { - responses.add(StreamParseResponse.Text(prevMessage)) - } - - responses.add( - StreamParseResponse.CodeHeader( - currentLanguage ?: "", - currentFilePath - ) - ) - - if (messageBuilder.isNotEmpty()) { - responses.add( - StreamParseResponse.CodeContent( - messageBuilder.toString(), - currentLanguage ?: "", - currentFilePath - ) - ) - } - - return responses - } - - private fun handleThinking(): List { - val startIndex = messageBuilder.indexOf(THINK_START_TAG) - - if (messageBuilder.contains(" contentEndIndex) { - return listOf(StreamParseResponse.Thinking("")) - } - - val thoughtContent = messageBuilder.substring(contentStartIndex, contentEndIndex) - return listOf(StreamParseResponse.Thinking(thoughtContent)) - } - - private fun handleProcessCode(endingIndex: Int): List { - val responses = mutableListOf() - isProcessingCode = false - - val codeContent = messageBuilder.substring(0, endingIndex) - - var deleteEndIndex = endingIndex + 3 - if (deleteEndIndex < messageBuilder.length && messageBuilder[deleteEndIndex] == '\n') { - deleteEndIndex++ - } - messageBuilder.delete(0, deleteEndIndex) - - if (codeContent.isNotEmpty()) { - responses.add( - StreamParseResponse.CodeContent( - codeContent, - currentLanguage ?: "", - currentFilePath - ) - ) - } - - responses.add( - StreamParseResponse.CodeEnd( - currentLanguage ?: "", - currentFilePath - ) - ) - - if (messageBuilder.isNotEmpty()) { - responses.add(StreamParseResponse.Text(messageBuilder.toString())) - } - - return responses - } - - fun parse(message: String): List { - val sanitizedMessage = message.replace("\r", "") - messageBuilder.append(sanitizedMessage) - - if (messageBuilder.length < THINK_START_TAG.length) { - return emptyList(); - } - - val isThinking = - messageBuilder.startsWith(THINK_START_TAG) || THINK_START_TAG.startsWith(messageBuilder) - if (isThinking) { - return handleThinking() - } - - if (isProcessingCode) { - val endingIndex = messageBuilder.indexOf("```") - if (endingIndex >= 0) { - return handleProcessCode(endingIndex) - } - } else { - val matcher = CODE_BLOCK_PATTERN.find(messageBuilder.toString()) - if (matcher != null) { - return handleProcessText(matcher) - } - } - - return if (isProcessingCode) { - listOf( - StreamParseResponse.CodeContent( - messageBuilder.toString(), - currentLanguage ?: "", - currentFilePath - ) - ) - } else { - listOf(StreamParseResponse.Text(messageBuilder.toString())) - } - } - - fun clear() { - messageBuilder.setLength(0) - isProcessingCode = false - currentLanguage = null - currentFilePath = null - } -} diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index f4aded8d..a60dc34d 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -152,6 +152,8 @@ configurationConfigurable.section.codeCompletion.collectDependencyStructure.titl configurationConfigurable.section.codeCompletion.collectDependencyStructure.description=Enabling the setting allows the plugin to collect the dependency structure, which increases the accuracy of the proposed data, but consumes more tokens per request. Currently, it is implemented only for the Kotlin language. configurationConfigurable.section.codeCompletion.gitDiff.description=If checked, the user's most recent unstaged git diff will be included when requesting completion. configurationConfigurable.section.chatCompletion.title=Chat Completion +configurationConfigurable.section.chatCompletion.retryOnFailedDiffSearch.title=Enable retry on failed diff search +configurationConfigurable.section.chatCompletion.retryOnFailedDiffSearch.description=If checked, the plugin will retry the diff search if it fails. configurationConfigurable.section.chatCompletion.editorContextTag.title=Enable automatic file tagging configurationConfigurable.section.chatCompletion.editorContextTag.description=If enabled, the content from open editor files will be automatically included with each message you send. configurationConfigurable.section.chatCompletion.psiStructure.title=Enable dependency structure analysis of attached files. @@ -205,8 +207,11 @@ toolwindow.chat.editor.action.autoApply.disabledTitle=Auto apply is only availab toolwindow.chat.editor.action.autoApply.description=Apply suggested changes automatically toolwindow.chat.editor.action.autoApply.noActiveFile=Active file not found toolwindow.chat.editor.action.autoApply.fileTooLarge=Active file too large to process -toolwindow.chat.editor.action.autoApply.accept=Accept All toolwindow.chat.editor.action.autoApply.reject=Reject All +toolwindow.chat.editor.diff.reading=Reading... +toolwindow.chat.editor.diff.thinking=Thinking... +toolwindow.chat.editor.diff.editing=Editing... +toolwindow.chat.editor.diff.retrying=Retrying... toolwindow.chat.editor.action.autoApply.error=Something went wrong while applying changes. {0} toolwindow.chat.editor.action.autoApply.taskTitle=Apply changes toolwindow.chat.editor.action.autoApply.loadingMessage=ProxyAI: Applying changes @@ -254,6 +259,7 @@ notification.compilationError.description=ProxyAI has detected a compilation err notification.compilationError.okLabel=Resolve errors notification.completionError.description=Completion failed:
%s statusBar.widget.tooltip=ProxyAI: Status +shared.acceptAll=Accept All shared.promptTemplate=Prompt template: shared.infillPromptTemplate=Infill template: shared.apiVersion=API version: diff --git a/src/main/resources/prompts/core/auto-apply.txt b/src/main/resources/prompts/core/auto-apply.txt new file mode 100644 index 00000000..d21dc929 --- /dev/null +++ b/src/main/resources/prompts/core/auto-apply.txt @@ -0,0 +1,41 @@ +You are an AI assistant specialized in applying code snippets to source files using SEARCH/REPLACE operations. Your task is to generate appropriate SEARCH/REPLACE blocks to implement required changes based on the provided source file content and code snippet. + +Follow these steps and guidelines: + +1. Generate a search block: + - Ensure it accurately matches a portion of the source code. + - Include enough context to make the match unique within the file. + +2. Create a SEARCH/REPLACE block: + - Use the search block you've generated. + - Incorporate the code snippet into the replace content. + +3. Format your output according to these rules: + - Start with the opening fence and code language, e.g., ```python + - Provide the full file path on the same line after a colon (make an educated guess if necessary) + - Use <<<<<<< SEARCH to start the search block + - Include the exact lines to search for in the existing source code + - Use ======= as a dividing line + - Provide the lines to replace into the source code, incorporating the code snippet + - Use >>>>>>> REPLACE to end the replace block + - Close with the closing fence: ``` + +Important guidelines: +- The SEARCH section must exactly match the existing file content, including all comments, docstrings, and whitespace. +- For code wrapped in containers (json, xml, quotes), propose edits to the literal contents, including the container markup. +- SEARCH/REPLACE blocks will only replace the first match occurrence. +- Include enough lines in the SEARCH section to uniquely match the lines that need to change. +- Always provide full file paths, even if you need to make an educated guess based on common project structures. +- Always put the file path after the colon on the same line as the opening fence. + +Example output structure (note: this is a generic example, your actual output should be based on the provided source file and code snippet): + +```[language]:/path/to/file/example.[ext] +<<<<<<< SEARCH +[Exact lines from the source file to be replaced] +======= +[New lines incorporating the code snippet] +>>>>>>> REPLACE +``` + +Provide your SEARCH/REPLACE block output without any additional explanation or commentary. \ No newline at end of file diff --git a/src/main/resources/prompts/persona/default-persona.txt b/src/main/resources/prompts/persona/default-persona.txt index b34bd5c4..83abdfcd 100644 --- a/src/main/resources/prompts/persona/default-persona.txt +++ b/src/main/resources/prompts/persona/default-persona.txt @@ -1,41 +1,55 @@ -You are an AI programming assistant integrated into a JetBrains IDE plugin. Your primary function is to provide code suggestions, technical information, and programming-related assistance within the IDE environment. You will receive a project path and a user query, and must respond accordingly. +You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information: + +Before we proceed with the main instructions, here is the content of relevant files in the project: -Here is the project path: {{project_path}} -Instructions for your response: +Instructions: -1. Analyze the project structure based on the given project path. -2. Determine if the query is code-related or a request for technical information. -3. If code-related: - a. Identify the most appropriate programming language based on the query context and project structure. - b. Determine a suitable file path for the code. IMPORTANT: Always generate a full file path, not just a filename. If there's no explicit context for the file location, make an educated guess based on common project structures or any information provided in the query or project path. -4. If it's a request for technical information, outline the key points you'll cover in your explanation. +1. Detect the intent behind the user's query: + - New code suggestion + - Technical explanation + - Code refactoring or editing -After your analysis, provide your response using the following structure: +2. For queries not related to the codebase or for new files, provide a standard code or text block response. -1. Begin with a brief, impersonal response that directly addresses the query. -2. For code-related queries, provide the code suggestion in a Markdown code block with this format: - ```[language]:[full_file_path] - // Code content +3. For refactoring or editing an existing file, always generate a SEARCH/REPLACE block. + +4. For any code generation, refactoring, or editing task: + a. First, outline an implementation plan describing the steps to address the user's request. + b. As you generate code or SEARCH/REPLACE blocks, reference the relevant step(s) from your plan, explaining your approach for each change. + c. For complex tasks, break down the plan and code changes into smaller steps, presenting each with its rationale and code diff together. + d. If the user's intent is unclear, ask clarifying questions before proceeding. + +5. When generating SEARCH/REPLACE blocks: + a. Ensure each block represents an atomic, non-overlapping change that can be applied independently. + b. Provide sufficient context in the SEARCH part to uniquely locate the change. + c. Keep SEARCH blocks concise while including necessary surrounding lines. + +Formatting Guidelines: + +1. Begin with a brief, impersonal acknowledgment. + +2. Use the following format for code blocks: + ```[language]:[absolute_file_path] + [code content] ``` -3. Add a brief (1-2 sentence) explanation after each code block. -4. For technical information queries, provide a concise explanation of key points. -Example output structure: +3. For new files, show the entire file content in a single code fence. -[Brief, impersonal response to the query] +4. For editing existing files, use this SEARCH/REPLACE structure: + ```[language]:[full_file_path] + <<<<<<< SEARCH + [exact lines from the file, including whitespace/comments] + ======= + [replacement lines] + >>>>>>> REPLACE + ``` -```[language]:[full_file_path] -[Code content] -``` +5. Always include a brief description (maximum 2 sentences) before each code block. -[Short description of the code suggestion] +6. Do not provide an implementation plan for pure explanations or general questions. -[Concise explanation of key points] - -Remember: -- Always provide full file paths, even if you need to make an educated guess based on common project structures. -- Include brief descriptions between each code block for better visual presentation in the UI. \ No newline at end of file +7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required. \ No newline at end of file diff --git a/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt index 682b40ec..3d7464b5 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt @@ -32,7 +32,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { "messages" ) .containsExactly( - "gpt-4", + "gpt-4o", listOf( mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"), mapOf("role" to "user", "content" to "TEST_PROMPT") diff --git a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt index d0d79247..ae9a5093 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt @@ -42,7 +42,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { "messages" ) .containsExactly( - "gpt-4", + "gpt-4o", listOf( mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"), mapOf("role" to "user", "content" to "Hello!") @@ -119,7 +119,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { "messages" ) .containsExactly( - "gpt-4", + "gpt-4o", listOf( mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"), mapOf( @@ -127,25 +127,16 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { "content" to """ Use the following context to answer question at the end: - File Path: /TEST_FILE_NAME_1 - File Content: - ```/TEST_FILE_NAME_1:/TEST_FILE_NAME_1 TEST_FILE_CONTENT_1 ``` - File Path: /TEST_FILE_NAME_2 - File Content: - ```/TEST_FILE_NAME_2:/TEST_FILE_NAME_2 TEST_FILE_CONTENT_2 ``` - File Path: /TEST_FILE_NAME_3 - File Content: - ```/TEST_FILE_NAME_3:/TEST_FILE_NAME_3 TEST_FILE_CONTENT_3 ``` @@ -323,7 +314,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { "messages" ) .containsExactly( - "gpt-4", + "gpt-4o", listOf( mapOf( "role" to "system", @@ -334,25 +325,16 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { "content" to """ Use the following context to answer question at the end: - File Path: /TEST_FILE_NAME_1 - File Content: - ```/TEST_FILE_NAME_1:/TEST_FILE_NAME_1 TEST_FILE_CONTENT_1 ``` - File Path: /TEST_FILE_NAME_2 - File Content: - ```/TEST_FILE_NAME_2:/TEST_FILE_NAME_2 TEST_FILE_CONTENT_2 ``` - File Path: /TEST_FILE_NAME_3 - File Content: - ```/TEST_FILE_NAME_3:/TEST_FILE_NAME_3 TEST_FILE_CONTENT_3 ``` diff --git a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/CompleteOutputParserTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/CompleteOutputParserTest.kt deleted file mode 100644 index 871b8767..00000000 --- a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/CompleteOutputParserTest.kt +++ /dev/null @@ -1,331 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat - -import ee.carlrobert.codegpt.toolwindow.chat.parser.CompleteOutputParser -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse.StreamResponseType -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test - -class CompleteOutputParserTest { - - @Test - fun `parse should return empty list for empty input`() { - val input = "" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).isEmpty() - } - - @Test - fun `parse should return single text element for text only input`() { - val input = "This is just plain text without any code blocks." - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = input) - ) - } - - @Test - fun `parse should handle single code block with language`() { - val language = "java" - val code = """ - public class Test { - public static void main(String[] args) { - System.out.println("Hello"); - } - } - """.trimIndent() - val input = "Here's some Java code:\n```java\n$code\n```\nEnd of example." - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Here's some Java code:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = language), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = language - ), - expectedResponse( - StreamResponseType.CODE_END, - language = language - ), - expectedResponse(StreamResponseType.TEXT, content = "\nEnd of example.") - ) - } - - @Test - fun `parse should handle code block with file path`() { - val language = "python" - val filePath = "src/main.py" - val code = """ - def hello(): - print('Hello, world!') - """.trimIndent() - val input = "```python:src/main.py\n$code\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse( - StreamResponseType.CODE_HEADER, - language = language, - filePath = filePath - ), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = language, - filePath = filePath - ), - expectedResponse(StreamResponseType.CODE_END, language = language, filePath = filePath) - ) - } - - @Test - fun `parse should handle multiple code blocks`() { - val javaCode = "System.out.println();" - val pythonCode = "print('hello')" - val input = - "First block:\n```java\n$javaCode\n```\nSecond block:\n```python\n$pythonCode\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "First block:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$javaCode\n", - language = "java" - ), - expectedResponse(StreamResponseType.CODE_END, language = "java"), - expectedResponse(StreamResponseType.TEXT, content = "\nSecond block:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "python"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$pythonCode\n", - language = "python" - ), - expectedResponse(StreamResponseType.CODE_END, language = "python") - ) - } - - @Test - fun `parse should handle code block without language`() { - val code = "const x = 10;" - val input = "Code without language specification:\n```\n$code\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse( - StreamResponseType.TEXT, - content = "Code without language specification:\n" - ), - expectedResponse( - StreamResponseType.CODE_HEADER, - language = "" - ), - expectedResponse(StreamResponseType.CODE_CONTENT, content = "$code\n", language = ""), - expectedResponse(StreamResponseType.CODE_END, language = "") - ) - } - - @Test - fun `parse should handle windows line endings`() { - val code = "System.out.println();" - val input = "Windows line endings:\r\n```java\r\n$code\r\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Windows line endings:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = "java" - ), - expectedResponse(StreamResponseType.CODE_END, language = "java") - ) - } - - @Test - fun `parse should handle nested backticks within code block`() { - val code = "console.log(`Template literal with backticks`);" - val input = "Nested backticks example:\n```javascript\n$code\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Nested backticks example:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "javascript"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = "javascript" - ), - expectedResponse(StreamResponseType.CODE_END, language = "javascript") - ) - } - - @Test - fun `parse should handle special characters in language specifier`() { - val code = "std::cout << \"Hello\";" - val input = "Special language name:\n```c++\n$code\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Special language name:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "c++"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = "c++" - ), - expectedResponse(StreamResponseType.CODE_END, language = "c++") - ) - } - - @Test - fun `parse should treat incomplete code block as text`() { - val input = "Incomplete code block:\n```java\nSystem.out.println();" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = input) - ) - } - - @Test - fun `parse should handle adjacent code blocks`() { - val javaCode = "int x = 1;" - val pythonCode = "print(2)" - val input = "```java\n$javaCode\n``````python\n$pythonCode\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$javaCode\n", - language = "java" - ), - expectedResponse(StreamResponseType.CODE_END, language = "java"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "python"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$pythonCode\n", - language = "python" - ), - expectedResponse(StreamResponseType.CODE_END, language = "python") - ) - } - - @Test - fun `parse should handle code block with empty content`() { - val input = "Empty code block:\n```java\n\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Empty code block:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "\n", - language = "java" - ), - expectedResponse(StreamResponseType.CODE_END, language = "java") - ) - } - - @Test - fun `parse should handle hyphen in language specifier`() { - val code = "NSLog(@\"Hello\");" - val input = "Language with hyphen:\n```objective-c\n$code\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Language with hyphen:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "objective-c"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = "objective-c" - ), - expectedResponse(StreamResponseType.CODE_END, language = "objective-c") - ) - } - - @Test - fun `parse should handle plus in language specifier`() { - val code = "std::cout << \"Hello\";" - val input = "Language with plus:\n```c++\n$code\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Language with plus:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "c++"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = "c++" - ), - expectedResponse(StreamResponseType.CODE_END, language = "c++") - ) - } - - @Test - fun `parse should handle underscore in language specifier`() { - val code = "print(\"Hello\");" - val input = "Language with underscore:\n```some_lang\n$code\n```" - - val result = CompleteOutputParser().parse(input) - - assertThat(result).containsExactly( - expectedResponse(StreamResponseType.TEXT, content = "Language with underscore:\n"), - expectedResponse(StreamResponseType.CODE_HEADER, language = "some_lang"), - expectedResponse( - StreamResponseType.CODE_CONTENT, - content = "$code\n", - language = "some_lang" - ), - expectedResponse(StreamResponseType.CODE_END, language = "some_lang") - ) - } - - private fun expectedResponse( - type: StreamResponseType, - content: String? = null, - language: String? = null, - filePath: String? = null - ): StreamParseResponse { - return when (type) { - StreamResponseType.TEXT -> StreamParseResponse.Text(content ?: "") - StreamResponseType.THINKING -> StreamParseResponse.Thinking(content ?: "") - StreamResponseType.CODE_HEADER -> StreamParseResponse.CodeHeader( - language ?: "", - filePath - ) - - StreamResponseType.CODE_CONTENT -> StreamParseResponse.CodeContent( - content ?: "", - language ?: "", - filePath - ) - - StreamResponseType.CODE_END -> StreamParseResponse.CodeEnd(language ?: "", filePath) - } - } -} \ No newline at end of file diff --git a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/StreamOutputParserTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/StreamOutputParserTest.kt deleted file mode 100644 index 58daedd0..00000000 --- a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/StreamOutputParserTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat - -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamOutputParser -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse -import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse.StreamResponseType -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.groups.Tuple -import org.junit.Before -import org.junit.Test -import kotlin.random.Random - -class StreamOutputParserTest { - - private lateinit var streamOutputParser: StreamOutputParser - - @Before - fun setUp() { - streamOutputParser = StreamOutputParser() - } - - @Test - fun testTextOnlyInput() { - val input = "This is just plain text without any code blocks." - - val result = streamOutputParser.parse(input) - - assertThat(result).hasSize(1) - assertThat(result[0].type).isEqualTo(StreamResponseType.TEXT) - assertThat(result[0].content).isEqualTo(input) - assertThat(result[0].language).isNull() - assertThat(result[0].filePath).isNull() - } - - @Test - fun testMultipleCodeBlocksWithThinking() { - val input = """ - - Here's some long thinking process - - - Here's some Java code: - - ```java - public class Test { - public static void main(String[] args) { - System.out.println("Hello"); - } - } - ``` - - Here's some Python code: - ```python:/path/to/my/file.py - def hello(): - print("Hello") - ``` - - Here's a basic markdown: - ``` - Some basic text - ``` - - End of `example`. - """.trimIndent() - - val response = simulateStreamedInput(input) - - assertThat(response.flatten()) - .extracting({ it.type }, { it.content.trim() }, { it.language }, { it.filePath }) - .contains( - Tuple.tuple(StreamResponseType.THINKING, "Here's some long thinking process", null, null), - Tuple.tuple(StreamResponseType.TEXT, "Here's some Java code:", null, null), - Tuple.tuple(StreamResponseType.CODE_HEADER, "", "java", null), - Tuple.tuple( - StreamResponseType.CODE_CONTENT, "public class Test {\n" + - " public static void main(String[] args) {\n" + - " System.out.println(\"Hello\");\n" + - " }\n" + - "}", "java", null - ), - Tuple.tuple(StreamResponseType.TEXT, "Here's some Python code:", null, null), - Tuple.tuple(StreamResponseType.CODE_HEADER, "", "python", "/path/to/my/file.py"), - Tuple.tuple( - StreamResponseType.CODE_CONTENT, - "def hello():\n print(\"Hello\")", - "python", - "/path/to/my/file.py" - ), - Tuple.tuple(StreamResponseType.TEXT, "Here's a basic markdown:", null, null), - Tuple.tuple(StreamResponseType.CODE_HEADER, "", "", null), - Tuple.tuple(StreamResponseType.CODE_CONTENT, "Some basic text", "", null), - Tuple.tuple(StreamResponseType.TEXT, "End of `example`.", null, null) - ) - } - - @Test - fun testMultipleCodeBlocksWithoutThinking() { - val input = """ - Here's some Java code: - - ```java - public class Test { - public static void main(String[] args) { - System.out.println("Hello"); - } - } - ``` - - Here's some Python code: - ```python:/path/to/my/file.py - def hello(): - print("Hello") - ``` - - Here's a basic markdown: - ``` - Some basic text - ``` - - End of `example`. - """.trimIndent() - - val response = simulateStreamedInput(input) - - assertThat(response.flatten()) - .extracting({ it.type }, { it.content.trim() }, { it.language }, { it.filePath }) - .contains( - Tuple.tuple(StreamResponseType.TEXT, "Here's some Java code:", null, null), - Tuple.tuple(StreamResponseType.CODE_HEADER, "", "java", null), - Tuple.tuple( - StreamResponseType.CODE_CONTENT, "public class Test {\n" + - " public static void main(String[] args) {\n" + - " System.out.println(\"Hello\");\n" + - " }\n" + - "}", "java", null - ), - Tuple.tuple(StreamResponseType.TEXT, "Here's some Python code:", null, null), - Tuple.tuple(StreamResponseType.CODE_HEADER, "", "python", "/path/to/my/file.py"), - Tuple.tuple( - StreamResponseType.CODE_CONTENT, - "def hello():\n print(\"Hello\")", - "python", - "/path/to/my/file.py" - ), - Tuple.tuple(StreamResponseType.TEXT, "Here's a basic markdown:", null, null), - Tuple.tuple(StreamResponseType.CODE_HEADER, "", "", null), - Tuple.tuple(StreamResponseType.CODE_CONTENT, "Some basic text", "", null), - Tuple.tuple(StreamResponseType.TEXT, "End of `example`.", null, null) - ) - } - - /** - * Simulates streaming input by breaking the input string into random chunks - * and feeding them to the StreamParser. - * - * @param input The complete input string - * @return List of responses from each parse call - */ - private fun simulateStreamedInput(input: String): List> { - streamOutputParser.clear() - val responses = mutableListOf>() - var remainingInput = input - - while (remainingInput.isNotEmpty()) { - // Take a random chunk size between 1 and the remaining length - val chunkSize = Random.nextInt(1, minOf(remainingInput.length + 1, 10)) - val chunk = remainingInput.substring(0, chunkSize) - remainingInput = remainingInput.substring(chunkSize) - - val response = streamOutputParser.parse(chunk) - responses.add(response) - } - - return responses - } -} diff --git a/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt b/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt index f70ee607..c1c6f2a4 100644 --- a/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt +++ b/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt @@ -28,7 +28,7 @@ interface ShortcutsTestMixin { } } - fun useOpenAIService(chatModel: String? = "gpt-4") { + fun useOpenAIService(chatModel: String? = "gpt-4o") { service().state.selectedService = ServiceType.OPENAI setCredential(OpenaiApiKey, "TEST_API_KEY") service().state.run {