feat: diff based editing

This commit is contained in:
Carl-Robert Linnupuu 2025-05-27 16:41:48 +01:00
parent 59d3191957
commit 9b6cb9de2c
50 changed files with 2525 additions and 1598 deletions

View file

@ -70,6 +70,15 @@ public final class CompletionRequestService {
return getChatCompletion(request);
}
public EventSource getCodeEditsAsync(
AutoApplyParameters params,
CompletionEventListener<String> eventListener) {
var request = CompletionRequestFactory
.getFactory(GeneralSettings.getSelectedService())
.createAutoApplyRequest(params);
return getChatCompletionAsync(request, eventListener);
}
public EventSource getCommitMessageAsync(
CommitMessageCompletionParameters params,
CompletionEventListener<String> eventListener) {

View file

@ -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()));

View file

@ -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())

View file

@ -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<WebSearchEventDetails> 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(
"<html><p style=\"margin-top: 4px; margin-bottom: 8px;\">&#8205;</p></html>");
@ -237,13 +242,14 @@ public class ChatMessageResponseBody extends JPanel {
webpageListPanel.setVisible(false);
}
String formattedMessage = format(
"<html><p style=\"margin-top: 4px; margin-bottom: 8px;\">%s</p></html>", message);
if (currentlyProcessedTextPane == null) {
currentlyProcessedTextPane = createTextPane("");
currentlyProcessedTextPane = createTextPane(formattedMessage, false);
contentPanel.add(currentlyProcessedTextPane);
}
String formattedMessage = format(
"<html><p style=\"margin-top: 4px; margin-bottom: 8px;\">%s</p></html>", 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())) {

View file

@ -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

View file

@ -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<PromptsSettings>().state.coreActions.autoApply.instructions
?: CoreActionsState.DEFAULT_AUTO_APPLY_PROMPT, prompt, 8192, true
)
}
abstract fun createBasicCompletionRequest(
systemPrompt: String,
userPrompt: String,

View file

@ -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"))

View file

@ -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<OpenAISettings>().state.model
val systemPrompt = service<PromptsSettings>().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<OpenAISettings>().state.model
val (gitDiff, systemPrompt) = params

View file

@ -261,7 +261,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

View file

@ -9,6 +9,11 @@ import ee.carlrobert.codegpt.CodeGPTBundle
class ChatCompletionConfigurationForm {
private val retryOnFailedDiffSearchCheckBox = JBCheckBox(
CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.retryOnFailedDiffSearch.title"),
service<ConfigurationSettings>().state.chatCompletionSettings.retryOnFailedDiffSearchEnabled
)
private val editorContextTagCheckBox = JBCheckBox(
CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.editorContextTag.title"),
service<ConfigurationSettings>().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
}

View file

@ -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)
}

View file

@ -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"

View file

@ -158,11 +158,12 @@ class PromptsForm {
val coreActionsFormState = getFormState<CoreActionPromptDetails>(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<ChatActionPromptDetails>(chatActionsNode)
.map { it.toState() }
@ -210,6 +211,7 @@ class PromptsForm {
val formState = getFormState<CoreActionPromptDetails>(coreActionsNode)
val stateActions = listOf(
settingsState.autoApply,
settingsState.editCode,
settingsState.fixCompileErrors,
settingsState.generateCommitMessage,
@ -265,6 +267,7 @@ class PromptsForm {
val settings = service<PromptsSettings>().state
listOf(
settings.coreActions.autoApply,
settings.coreActions.editCode,
settings.coreActions.fixCompileErrors,
settings.coreActions.generateCommitMessage,
@ -584,6 +587,7 @@ class PromptsForm {
private fun insertCorePrompts(prompts: CoreActionsState) {
coreActionsNode.removeAllChildren()
listOf(
prompts.autoApply,
prompts.editCode,
prompts.fixCompileErrors,
prompts.generateCommitMessage,

View file

@ -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<PromptsSettings>().state.coreActions
listOf(
settings.autoApply,
settings.editCode,
settings.fixCompileErrors,
settings.generateCommitMessage,

View file

@ -1,156 +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.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.layoutPolicy = ActionToolbar.NOWRAP_LAYOUT_POLICY
toolbar.targetComponent = this
toolbar.component.border = JBUI.Borders.empty()
toolbar.updateActionsImmediately()
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!!.updateActionsImmediately()
revalidate()
repaint()
}
}
}

View file

@ -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<Boolean>("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<UnifiedDiffViewer?>("proxyai.responseEditorDiffViewer")
val RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY =
Key.create<Pair<String, String>>("proxyai.responseEditorDiffViewerValuePair")
val RESPONSE_EDITOR_STATE_KEY = Key.create<EditorState>("proxyai.responseEditorState")
}
val editor: Editor
private val expandLinkPanel: JPanel
private var expandLinkAdded = false
private val stateManager = project.service<EditorStateManager>()
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(
)
}
}
}
}

View file

@ -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<String> {
private val logger = logger<RetryListener>()
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)
}
}

View file

@ -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<ConfigurationSettings>().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)
}
}
}
}

View file

@ -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) {
}
data class ToolWindowEditorFileDetails(val path: String, val virtualFile: VirtualFile? = null)

View file

@ -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<String>("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<DiffManager>().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<SimpleDiffRequestChain.DiffRequestProducerWrapper>()
.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<Pair<String, () -> 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)
}
}
}

View file

@ -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<UnifiedDiffChange>,
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)
}
}
}

View file

@ -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<UnifiedDiffChange> {
val document = diffViewer.getDocument(Side.LEFT)
DiffManagerUtil.ensureDocumentWritable(project, document)
val allChanges = mutableListOf<UnifiedDiffChange>()
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)
}
}
}

View file

@ -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<UnifiedDiffChange>): 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<UnifiedDiffChange>) {
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(" ")
}
}
}
}

View file

@ -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<String, MutableSet<EditorEx>>()
private val fileToListener = ConcurrentHashMap<String, DocumentListener>()
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)
}
}
}
}
}

View file

@ -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<UnifiedDiffChange>): Triple<Int, Int, Int> =
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)
}
}

View file

@ -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<Boolean>("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")
}
}
}

View file

@ -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("<<<")
}
}

View file

@ -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<EditorStateManager>()
.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)
}
}
}
}
}

View file

@ -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<UnifiedDiffChange>
) {
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<UnifiedDiffChange>) {
runInEdt {
DiffStatsComponent.updateStatsComponent(statsComponent, changes)
revalidate()
repaint()
}
}
fun editing() {
runInEdt {
loadingLabel.text = CodeGPTBundle.get("toolwindow.chat.editor.diff.editing")
loadingLabel.isVisible = true
}
}
}

View file

@ -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)
}
}
}

View file

@ -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<String>("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<DiffManager>().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<SimpleDiffRequestChain.DiffRequestProducerWrapper>()
.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
}
}
}
}

View file

@ -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?
}

View file

@ -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) }
}
}

View file

@ -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))
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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 = "<think>"
private const val THINK_CLOSE_TAG = "</think>\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<Segment> {
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<Segment> = 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<Segment>.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<Segment>.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<Segment>.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<Segment> = 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<Segment>.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<Segment>.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<Segment> {
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<Segment>,
val endIndex: Int
)
}

View file

@ -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 = "<think>"
private const val THINK_CLOSE_TAG = "</think>\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<StreamParseResponse> {
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))
}
}
}
}
}

View file

@ -0,0 +1,6 @@
package ee.carlrobert.codegpt.toolwindow.chat.parser
interface MessageParser {
fun parse(input: String): List<Segment>
}

View file

@ -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)

View file

@ -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<Segment>()
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<Segment> {
buffer.append(input)
val output = mutableListOf<Segment>()
loop@ while (true) {
when (state) {
State.OUTSIDE -> {
val fenceIdx = buffer.indexOf("```")
val thinkStartIdx = buffer.indexOf("<think>")
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 + "<think>".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("</think>")
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 + "</think>".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
}
}

View file

@ -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 = "<think>"
private const val THINK_END_TAG = "</think>"
}
private val messageBuilder = StringBuilder()
private var isProcessingCode = false
private var currentLanguage: String? = null
private var currentFilePath: String? = null
private fun handleProcessText(matcher: MatchResult): List<StreamParseResponse> {
val responses = mutableListOf<StreamParseResponse>()
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<StreamParseResponse> {
val startIndex = messageBuilder.indexOf(THINK_START_TAG)
if (messageBuilder.contains("</")) {
val endIndex =
messageBuilder.indexOf(THINK_END_TAG, startIndex + THINK_START_TAG.length)
if (endIndex != -1) {
messageBuilder.delete(startIndex, endIndex + THINK_END_TAG.length)
}
}
val partialEndIndex = messageBuilder.indexOf("</", startIndex + THINK_START_TAG.length)
val contentEndIndex = if (partialEndIndex != -1) {
partialEndIndex
} else {
messageBuilder.length
}
val contentStartIndex = startIndex + THINK_START_TAG.length
if (contentStartIndex > contentEndIndex) {
return listOf(StreamParseResponse.Thinking(""))
}
val thoughtContent = messageBuilder.substring(contentStartIndex, contentEndIndex)
return listOf(StreamParseResponse.Thinking(thoughtContent))
}
private fun handleProcessCode(endingIndex: Int): List<StreamParseResponse> {
val responses = mutableListOf<StreamParseResponse>()
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<StreamParseResponse> {
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
}
}

View file

@ -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:<br/>%s
statusBar.widget.tooltip=ProxyAI: Status
shared.acceptAll=Accept All
shared.promptTemplate=Prompt template:
shared.infillPromptTemplate=Infill template:
shared.apiVersion=API version:

View file

@ -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.

View file

@ -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>
{{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.
7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.

View file

@ -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")

View file

@ -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
```

View file

@ -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)
}
}
}

View file

@ -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 = """
<think>
Here's some long thinking process
</think>
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<List<StreamParseResponse>> {
streamOutputParser.clear()
val responses = mutableListOf<List<StreamParseResponse>>()
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
}
}

View file

@ -28,7 +28,7 @@ interface ShortcutsTestMixin {
}
}
fun useOpenAIService(chatModel: String? = "gpt-4") {
fun useOpenAIService(chatModel: String? = "gpt-4o") {
service<GeneralSettings>().state.selectedService = ServiceType.OPENAI
setCredential(OpenaiApiKey, "TEST_API_KEY")
service<OpenAISettings>().state.run {