mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-19 07:54:46 +00:00
feat: inline edit
This commit is contained in:
parent
80ba956c5e
commit
fdfde4243d
55 changed files with 4274 additions and 696 deletions
|
|
@ -94,7 +94,7 @@ git submodule update
|
|||
|
||||
**Tailing logs**
|
||||
```shell
|
||||
tail -f build/idea-sandbox/system/log/idea.log
|
||||
tail -f build/idea-sandbox/IC-2024.1.2/log/idea.log
|
||||
```
|
||||
|
||||
## Privacy
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package ee.carlrobert.codegpt;
|
||||
|
||||
import com.intellij.openapi.util.Key;
|
||||
import ee.carlrobert.codegpt.inlineedit.InlineEditSession;
|
||||
import ee.carlrobert.codegpt.inlineedit.InlineEditInlayRenderer;
|
||||
import ee.carlrobert.codegpt.predictions.CodeSuggestionDiffViewer;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.ToolWindowEditorFileDetails;
|
||||
import ee.carlrobert.llm.client.codegpt.CodeGPTUserDetails;
|
||||
import ee.carlrobert.service.NextEditResponse;
|
||||
import ee.carlrobert.service.PartialCodeCompletionResponse;
|
||||
import javax.swing.JComponent;
|
||||
|
||||
public class CodeGPTKeys {
|
||||
|
||||
|
|
@ -21,6 +24,12 @@ public class CodeGPTKeys {
|
|||
Key.create("codegpt.isPromptTextFieldDocument");
|
||||
public static final Key<CodeSuggestionDiffViewer> EDITOR_PREDICTION_DIFF_VIEWER =
|
||||
Key.create("codegpt.editorPredictionDiffViewer");
|
||||
public static final Key<InlineEditSession> EDITOR_INLINE_EDIT_SESSION =
|
||||
Key.create("codegpt.editorInlineEditSession");
|
||||
public static final Key<InlineEditInlayRenderer> EDITOR_INLINE_EDIT_RENDERER =
|
||||
Key.create("codegpt.editorInlineEditRenderer");
|
||||
public static final Key<JComponent> EDITOR_INLINE_EDIT_COMPARE_LINK =
|
||||
Key.create("codegpt.editorInlineEditCompareLink");
|
||||
public static final Key<PartialCodeCompletionResponse> REMAINING_CODE_COMPLETION =
|
||||
Key.create("codegpt.remainingCodeCompletion");
|
||||
public static final Key<NextEditResponse> REMAINING_PREDICTION_RESPONSE =
|
||||
|
|
|
|||
|
|
@ -37,17 +37,15 @@ public abstract class BaseEditorAction extends AnAction {
|
|||
var project = event.getProject();
|
||||
var editor = event.getData(PlatformDataKeys.EDITOR);
|
||||
if (editor != null && project != null) {
|
||||
actionPerformed(project, editor, editor.getSelectionModel().getSelectedText());
|
||||
var selectedText = editor.getSelectionModel().getSelectedText();
|
||||
actionPerformed(project, editor, selectedText != null ? selectedText : "");
|
||||
}
|
||||
}
|
||||
|
||||
public void update(AnActionEvent event) {
|
||||
Project project = event.getProject();
|
||||
Editor editor = event.getData(PlatformDataKeys.EDITOR);
|
||||
boolean menuAllowed = false;
|
||||
if (editor != null && project != null) {
|
||||
menuAllowed = editor.getSelectionModel().getSelectedText() != null;
|
||||
}
|
||||
boolean menuAllowed = editor != null && project != null;
|
||||
event.getPresentation().setEnabled(menuAllowed);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,15 +93,15 @@ public final class CompletionRequestService {
|
|||
return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.COMMIT_MESSAGE);
|
||||
}
|
||||
|
||||
public EventSource getEditCodeCompletionAsync(
|
||||
EditCodeCompletionParameters params,
|
||||
public EventSource getInlineEditCompletionAsync(
|
||||
InlineEditCompletionParameters params,
|
||||
CompletionEventListener<String> eventListener) {
|
||||
var serviceType =
|
||||
ModelSelectionService.getInstance().getServiceForFeature(FeatureType.EDIT_CODE);
|
||||
ModelSelectionService.getInstance().getServiceForFeature(FeatureType.INLINE_EDIT);
|
||||
var request = CompletionRequestFactory
|
||||
.getFactory(serviceType)
|
||||
.createEditCodeRequest(params);
|
||||
return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.EDIT_CODE);
|
||||
.createInlineEditRequest(params);
|
||||
return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.INLINE_EDIT);
|
||||
}
|
||||
|
||||
public EventSource getChatCompletionAsync(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ public enum FeatureType {
|
|||
CODE_COMPLETION,
|
||||
AUTO_APPLY,
|
||||
COMMIT_MESSAGE,
|
||||
EDIT_CODE,
|
||||
INLINE_EDIT,
|
||||
NEXT_EDIT,
|
||||
LOOKUP
|
||||
}
|
||||
|
|
@ -105,9 +105,12 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
project,
|
||||
totalTokensPanel,
|
||||
this,
|
||||
FeatureType.CHAT,
|
||||
tagManager,
|
||||
this::handleSubmit,
|
||||
this::handleCancel);
|
||||
this::handleCancel,
|
||||
true,
|
||||
true);
|
||||
userInputPanel.requestFocus();
|
||||
rootPanel = createRootPanel();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.intellij.openapi.application.ApplicationManager
|
|||
import com.intellij.openapi.application.ReadAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.progress.ProcessCanceledException
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.vfs.AsyncFileListener
|
||||
|
|
@ -181,8 +182,11 @@ class PsiStructureRepository(
|
|||
coroutineContext.ensureActive()
|
||||
try {
|
||||
PsiManager.getInstance(project).findFile(virtualFile)
|
||||
} catch (exc: Exception) {
|
||||
logger.warn("Failed to find file ${virtualFile.name}", exc)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is ProcessCanceledException) {
|
||||
throw ex
|
||||
}
|
||||
logger.warn("Failed to find file ${virtualFile.name}", ex)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -289,4 +293,4 @@ class PsiStructureRepository(
|
|||
}
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,12 +66,13 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
private final Project project;
|
||||
private final List<ServiceType> availableProviders;
|
||||
private final boolean showConfigureModels;
|
||||
private final FeatureType featureType;
|
||||
|
||||
public ModelComboBoxAction(
|
||||
Project project,
|
||||
Consumer<ServiceType> onModelChange,
|
||||
ServiceType selectedService) {
|
||||
this(project, onModelChange, selectedService, Arrays.asList(ServiceType.values()), true);
|
||||
this(project, onModelChange, selectedService, Arrays.asList(ServiceType.values()), true, FeatureType.CHAT);
|
||||
}
|
||||
|
||||
public ModelComboBoxAction(
|
||||
|
|
@ -80,10 +81,21 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
ServiceType selectedProvider,
|
||||
List<ServiceType> availableProviders,
|
||||
boolean showConfigureModels) {
|
||||
this(project, onModelChange, selectedProvider, availableProviders, showConfigureModels, FeatureType.CHAT);
|
||||
}
|
||||
|
||||
public ModelComboBoxAction(
|
||||
Project project,
|
||||
Consumer<ServiceType> onModelChange,
|
||||
ServiceType selectedProvider,
|
||||
List<ServiceType> availableProviders,
|
||||
boolean showConfigureModels,
|
||||
FeatureType featureType) {
|
||||
this.project = project;
|
||||
this.onModelChange = onModelChange;
|
||||
this.availableProviders = availableProviders;
|
||||
this.showConfigureModels = showConfigureModels;
|
||||
this.featureType = featureType;
|
||||
setSmallVariant(true);
|
||||
updateTemplatePresentation(selectedProvider);
|
||||
|
||||
|
|
@ -92,8 +104,12 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
ModelChangeNotifier.getTopic(),
|
||||
new ModelChangeNotifierAdapter() {
|
||||
@Override
|
||||
public void chatModelChanged(@NotNull String newModel, @NotNull ServiceType serviceType) {
|
||||
updateTemplatePresentation(serviceType);
|
||||
public void modelChanged(@NotNull FeatureType changedFeature,
|
||||
@NotNull String newModel,
|
||||
@NotNull ServiceType serviceType) {
|
||||
if (changedFeature == featureType) {
|
||||
updateTemplatePresentation(serviceType);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -275,12 +291,13 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
var application = ApplicationManager.getApplication();
|
||||
var templatePresentation = getTemplatePresentation();
|
||||
var chatModel = application.getService(ModelSettings.class).getState()
|
||||
.getModelSelection(FeatureType.CHAT);
|
||||
.getModelSelection(featureType);
|
||||
var modelCode = chatModel != null ? chatModel.getModel() : null;
|
||||
|
||||
switch (selectedService) {
|
||||
case PROXYAI:
|
||||
var proxyAIModel = ModelRegistry.getInstance().getProxyAIChatModels().stream()
|
||||
var proxyAIModel = ModelRegistry.getInstance().getAllModelsForFeature(featureType).stream()
|
||||
.filter(it -> it.getProvider() == PROXYAI)
|
||||
.filter(it -> modelCode != null && it.getModel().equals(modelCode))
|
||||
.findFirst();
|
||||
templatePresentation.setIcon(
|
||||
|
|
@ -391,7 +408,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
var application = ApplicationManager.getApplication();
|
||||
application
|
||||
.getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT, model.getModel(), PROXYAI);
|
||||
.setModel(featureType, model.getModel(), PROXYAI);
|
||||
|
||||
handleModelChange(PROXYAI);
|
||||
});
|
||||
|
|
@ -420,7 +437,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
.setModel(model);
|
||||
application
|
||||
.getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT, model, OLLAMA);
|
||||
.setModel(featureType, model, OLLAMA);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -434,7 +451,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
Icons.OpenAI,
|
||||
comboBoxPresentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT, model.getCode(), OPENAI));
|
||||
.setModel(featureType, model.getCode(), OPENAI));
|
||||
}
|
||||
|
||||
private AnAction createCustomOpenAIModelAction(
|
||||
|
|
@ -446,7 +463,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
Icons.OpenAI,
|
||||
comboBoxPresentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT, model.getName(), CUSTOM_OPENAI));
|
||||
.setModel(featureType, model.getName(), CUSTOM_OPENAI));
|
||||
}
|
||||
|
||||
private AnAction createGoogleModelAction(GoogleModel model, Presentation comboBoxPresentation) {
|
||||
|
|
@ -457,7 +474,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
Icons.Google,
|
||||
comboBoxPresentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT, model.getCode(), GOOGLE));
|
||||
.setModel(featureType, model.getCode(), GOOGLE));
|
||||
}
|
||||
|
||||
private AnAction createAnthropicModelAction(
|
||||
|
|
@ -470,7 +487,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
Icons.Anthropic,
|
||||
comboBoxPresentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT, modelCode, ANTHROPIC));
|
||||
.setModel(featureType, modelCode, ANTHROPIC));
|
||||
}
|
||||
|
||||
private AnAction createLlamaModelAction(Presentation comboBoxPresentation) {
|
||||
|
|
@ -480,7 +497,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
Icons.Llama,
|
||||
comboBoxPresentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT,
|
||||
.setModel(featureType,
|
||||
LlamaSettings.getCurrentState().getHuggingFaceModel().getCode(), LLAMA_CPP));
|
||||
}
|
||||
|
||||
|
|
@ -492,12 +509,12 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
Icons.Mistral,
|
||||
comboBoxPresentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(FeatureType.CHAT, modelCode, MISTRAL));
|
||||
.setModel(featureType, modelCode, MISTRAL));
|
||||
}
|
||||
|
||||
private String getMistralPresentationText() {
|
||||
var chatModel = ApplicationManager.getApplication().getService(ModelSettings.class).getState()
|
||||
.getModelSelection(FeatureType.CHAT);
|
||||
.getModelSelection(featureType);
|
||||
var modelCode = chatModel != null ? chatModel.getModel() : null;
|
||||
return ModelRegistry.getInstance().getModelDisplayName(MISTRAL, modelCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,4 +56,4 @@ class CodeGPTEditorFactoryListener : EditorFactoryListener {
|
|||
.syncPublisher(EditorNotifier.Released.TOPIC)
|
||||
.editorReleased(event.editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ import com.intellij.openapi.components.service
|
|||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.startup.ProjectActivity
|
||||
import ee.carlrobert.codegpt.actions.editor.EditorActionsUtil
|
||||
import ee.carlrobert.codegpt.settings.GeneralSettings
|
||||
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTService
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
|
|
|
|||
|
|
@ -5,24 +5,34 @@ import com.intellij.openapi.actionSystem.ActionPromoter
|
|||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import ee.carlrobert.codegpt.codecompletions.AcceptNextLineInlayAction
|
||||
import ee.carlrobert.codegpt.codecompletions.AcceptNextWordInlayAction
|
||||
import ee.carlrobert.codegpt.predictions.OpenPredictionAction
|
||||
import ee.carlrobert.codegpt.predictions.TriggerCustomPredictionAction
|
||||
import ee.carlrobert.codegpt.inlineedit.AcceptCurrentInlineEditAction
|
||||
import ee.carlrobert.codegpt.actions.editor.AcceptInlineEditAction
|
||||
import ee.carlrobert.codegpt.inlineedit.RejectCurrentInlineEditAction
|
||||
|
||||
class InlayActionPromoter : ActionPromoter {
|
||||
override fun promote(actions: List<AnAction>, context: DataContext): List<AnAction> {
|
||||
val editor = CommonDataKeys.EDITOR.getData(context) ?: return emptyList()
|
||||
|
||||
val hasInlineEdit = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null ||
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|
||||
if (hasInlineEdit) {
|
||||
actions.filterIsInstance<AcceptInlineEditAction>().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
actions.filterIsInstance<AcceptCurrentInlineEditAction>().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
actions.filterIsInstance<RejectCurrentInlineEditAction>().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
}
|
||||
|
||||
actions.filterIsInstance<TriggerCustomPredictionAction>().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
actions.filterIsInstance<OpenPredictionAction>().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
|
||||
if (InlineCompletionContext.getOrNull(editor) == null) {
|
||||
return emptyList()
|
||||
}
|
||||
if (InlineCompletionContext.getOrNull(editor) == null) return emptyList()
|
||||
|
||||
actions.filterIsInstance<AcceptNextWordInlayAction>().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
actions.filterIsInstance<AcceptNextLineInlayAction>().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
package ee.carlrobert.codegpt.actions.editor
|
||||
|
||||
import com.intellij.codeInsight.hint.HintManagerImpl
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.editor.Caret
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.actionSystem.EditorAction
|
||||
import com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
|
||||
class AcceptInlineEditAction : EditorAction(Handler()), HintManagerImpl.ActionToIgnore {
|
||||
|
||||
companion object {
|
||||
const val ID = "codegpt.acceptInlineEdit"
|
||||
}
|
||||
|
||||
private class Handler : EditorWriteActionHandler() {
|
||||
|
||||
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.let { session ->
|
||||
session.acceptNearestToCaret()
|
||||
return
|
||||
}
|
||||
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptNext()
|
||||
}
|
||||
|
||||
override fun isEnabledForCaret(
|
||||
editor: Editor,
|
||||
caret: Caret,
|
||||
dataContext: DataContext
|
||||
): Boolean {
|
||||
return editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null ||
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
package ee.carlrobert.codegpt.actions.editor
|
||||
|
||||
import com.intellij.ide.BrowserUtil
|
||||
import com.intellij.notification.NotificationAction
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runUndoTransparentWriteAction
|
||||
import com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.markup.*
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.codeStyle.CodeStyleManager
|
||||
import com.intellij.ui.JBColor
|
||||
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
|
||||
import ee.carlrobert.codegpt.util.ThinkingOutputParser
|
||||
import ee.carlrobert.codegpt.ui.ObservableProperties
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import ee.carlrobert.llm.client.openai.completion.ErrorDetails
|
||||
import ee.carlrobert.llm.completion.CompletionEventListener
|
||||
import okhttp3.sse.EventSource
|
||||
|
||||
class EditCodeCompletionListener(
|
||||
private val editor: Editor,
|
||||
private val observableProperties: ObservableProperties,
|
||||
private val selectionTextRange: TextRange
|
||||
) : CompletionEventListener<String> {
|
||||
|
||||
private var replacedLength = 0
|
||||
private var currentHighlighter: RangeHighlighter? = null
|
||||
private val thinkingOutputParser = ThinkingOutputParser()
|
||||
|
||||
override fun onMessage(message: String, eventSource: EventSource) {
|
||||
val processedChunk = thinkingOutputParser.processChunk(message)
|
||||
if (processedChunk.isNotEmpty() && thinkingOutputParser.isFinished) {
|
||||
runInEdt { handleDiff(processedChunk) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(messageBuilder: StringBuilder) {
|
||||
runInEdt {
|
||||
if (replacedLength == 0 && messageBuilder.isNotEmpty()) {
|
||||
handleDiff(messageBuilder.toString())
|
||||
}
|
||||
cleanupAndFormat()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
override fun onError(error: ErrorDetails, ex: Throwable) {
|
||||
OverlayUtil.showNotification(
|
||||
error.message,
|
||||
NotificationType.ERROR,
|
||||
NotificationAction.createSimpleExpiring("Upgrade plan") {
|
||||
BrowserUtil.open("https://tryproxy.io/#pricing")
|
||||
},
|
||||
)
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
private fun stopLoading() {
|
||||
observableProperties.loading.set(false)
|
||||
editor.project?.let {
|
||||
CompletionProgressNotifier.update(it, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHighlighter(editor: Editor) {
|
||||
cleanupHighlighter()
|
||||
|
||||
val document = editor.document
|
||||
val lineNumber = document.getLineNumber(editor.caretModel.offset)
|
||||
currentHighlighter = editor.markupModel.addRangeHighlighter(
|
||||
document.getLineStartOffset(lineNumber),
|
||||
document.getLineEndOffset(lineNumber),
|
||||
HighlighterLayer.SELECTION - 1,
|
||||
TextAttributes().apply {
|
||||
effectType = EffectType.BOXED
|
||||
effectColor =
|
||||
JBColor.namedColor("PsiViewer.referenceHighlightColor", 0x6A7B15)
|
||||
errorStripeColor = effectColor
|
||||
},
|
||||
HighlighterTargetArea.EXACT_RANGE
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDiff(message: String) {
|
||||
val document = editor.document
|
||||
val startOffset = selectionTextRange.startOffset
|
||||
val endOffset = selectionTextRange.endOffset
|
||||
runUndoTransparentWriteAction {
|
||||
val remainingOriginalLength = endOffset - (startOffset + replacedLength)
|
||||
if (remainingOriginalLength > 0) {
|
||||
document.replaceString(
|
||||
startOffset + replacedLength,
|
||||
startOffset + replacedLength + minOf(
|
||||
message.length,
|
||||
remainingOriginalLength
|
||||
),
|
||||
StringUtil.convertLineSeparators(message)
|
||||
)
|
||||
} else {
|
||||
document.insertString(startOffset + replacedLength, message)
|
||||
}
|
||||
}
|
||||
|
||||
replacedLength += message.length
|
||||
editor.caretModel.moveToOffset(startOffset + replacedLength)
|
||||
updateHighlighter(editor)
|
||||
}
|
||||
|
||||
private fun cleanupAndFormat() {
|
||||
val project = editor.project ?: return
|
||||
val document = editor.document
|
||||
val psiDocumentManager = project.service<PsiDocumentManager>()
|
||||
val psiFile = psiDocumentManager.getPsiFile(document) ?: return
|
||||
val startOffset = selectionTextRange.startOffset
|
||||
val endOffset = selectionTextRange.endOffset
|
||||
val newEndOffset = startOffset + replacedLength
|
||||
|
||||
runWriteCommandAction(project) {
|
||||
if (newEndOffset < endOffset) {
|
||||
document.deleteString(newEndOffset, endOffset)
|
||||
}
|
||||
psiDocumentManager.commitDocument(document)
|
||||
project.service<CodeStyleManager>().reformatText(
|
||||
psiFile,
|
||||
listOf(TextRange(startOffset, newEndOffset))
|
||||
)
|
||||
}
|
||||
|
||||
editor.caretModel.moveToOffset(newEndOffset)
|
||||
psiDocumentManager.doPostponedOperationsAndUnblockDocument(document)
|
||||
cleanupHighlighter()
|
||||
}
|
||||
|
||||
private fun cleanupHighlighter() {
|
||||
currentHighlighter?.let { editor.markupModel.removeHighlighter(it) }
|
||||
currentHighlighter = null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
package ee.carlrobert.codegpt.actions.editor
|
||||
|
||||
import com.intellij.openapi.application.readAction
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.jetbrains.rd.util.AtomicReference
|
||||
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
|
||||
import ee.carlrobert.codegpt.completions.CompletionRequestService
|
||||
import ee.carlrobert.codegpt.completions.EditCodeCompletionParameters
|
||||
import ee.carlrobert.codegpt.ui.ObservableProperties
|
||||
|
||||
class EditCodeSubmissionHandler(
|
||||
private val editor: Editor,
|
||||
private val observableProperties: ObservableProperties,
|
||||
) {
|
||||
|
||||
private val previousSourceRef = AtomicReference<String?>(null)
|
||||
|
||||
suspend fun handleSubmit(userPrompt: String) {
|
||||
editor.project?.let {
|
||||
CompletionProgressNotifier.update(it, true)
|
||||
}
|
||||
|
||||
observableProperties.loading.set(true)
|
||||
observableProperties.submitted.set(true)
|
||||
|
||||
previousSourceRef.getAndSet(editor.document.text)
|
||||
val (selectionTextRange, selectedText) = readAction {
|
||||
editor.selectionModel.run {
|
||||
Pair(
|
||||
TextRange(selectionStart, selectionEnd),
|
||||
editor.selectionModel.selectedText ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
runInEdt { editor.selectionModel.removeSelection() }
|
||||
|
||||
service<CompletionRequestService>().getEditCodeCompletionAsync(
|
||||
EditCodeCompletionParameters(userPrompt, selectedText),
|
||||
EditCodeCompletionListener(editor, observableProperties, selectionTextRange)
|
||||
)
|
||||
}
|
||||
|
||||
fun handleAccept() {
|
||||
observableProperties.accepted.set(true)
|
||||
observableProperties.submitted.set(false)
|
||||
}
|
||||
|
||||
fun handleReject() {
|
||||
val prevSource = previousSourceRef.get()
|
||||
if (!observableProperties.accepted.get() && prevSource != null) {
|
||||
revertAllChanges(prevSource)
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertAllChanges(prevSource: String) {
|
||||
runWriteCommandAction(editor.project) {
|
||||
editor.document.replaceString(
|
||||
0,
|
||||
editor.document.textLength,
|
||||
StringUtil.convertLineSeparators(prevSource)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package ee.carlrobert.codegpt.actions.editor
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.editor.ex.util.EditorUtil
|
||||
import com.intellij.openapi.editor.impl.EditorEmbeddedComponentManager
|
||||
import com.intellij.openapi.editor.impl.EditorImpl
|
||||
import com.intellij.openapi.editor.impl.view.FontLayoutService
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Dimension
|
||||
import java.awt.Font
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import javax.swing.JComponent
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Manages embedded component inlays in the main editor.
|
||||
*/
|
||||
class EditorComponentInlaysManager(val editor: EditorImpl) : Disposable {
|
||||
|
||||
private val managedInlays = mutableMapOf<ComponentWrapper, Disposable>()
|
||||
private val editorWidthWatcher = EditorTextWidthWatcher()
|
||||
|
||||
init {
|
||||
editor.scrollPane.viewport.addComponentListener(editorWidthWatcher)
|
||||
Disposer.register(this, Disposable {
|
||||
editor.scrollPane.viewport.removeComponentListener(editorWidthWatcher)
|
||||
})
|
||||
|
||||
EditorUtil.disposeWithEditor(editor, this)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun insert(lineIndex: Int, component: JComponent, showAbove: Boolean = false): Disposable? {
|
||||
val wrappedComponent = ComponentWrapper(component)
|
||||
val offset = if (lineIndex < editor.document.lineCount) {
|
||||
editor.document.getLineStartOffset(lineIndex)
|
||||
} else {
|
||||
editor.document.textLength
|
||||
}
|
||||
|
||||
val result = EditorEmbeddedComponentManager.getInstance()
|
||||
.addComponent(
|
||||
editor, wrappedComponent,
|
||||
EditorEmbeddedComponentManager.Properties(
|
||||
EditorEmbeddedComponentManager.ResizePolicy.none(),
|
||||
null,
|
||||
true,
|
||||
showAbove,
|
||||
0,
|
||||
offset
|
||||
)
|
||||
)
|
||||
|
||||
return result?.also {
|
||||
managedInlays[wrappedComponent] = it
|
||||
Disposer.register(it, Disposable {
|
||||
managedInlays.remove(wrappedComponent)
|
||||
})
|
||||
} ?: run {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ComponentWrapper(val component: JComponent) : JBScrollPane(component) {
|
||||
init {
|
||||
isOpaque = false
|
||||
viewport.isOpaque = false
|
||||
|
||||
border = JBUI.Borders.empty()
|
||||
viewportBorder = JBUI.Borders.empty()
|
||||
|
||||
horizontalScrollBarPolicy = HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBar.preferredSize = Dimension(0, 0)
|
||||
setViewportView(component)
|
||||
|
||||
component.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) =
|
||||
dispatchEvent(ComponentEvent(component, ComponentEvent.COMPONENT_RESIZED))
|
||||
})
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
return Dimension(editor.contentComponent.width, component.preferredSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
managedInlays.values.forEach(Disposer::dispose)
|
||||
}
|
||||
|
||||
private inner class EditorTextWidthWatcher : ComponentAdapter() {
|
||||
|
||||
var editorTextWidth: Int = 0
|
||||
|
||||
private val maximumEditorTextWidth: Int
|
||||
private val verticalScrollbarFlipped: Boolean
|
||||
|
||||
init {
|
||||
val metrics = editor.getFontMetrics(Font.PLAIN)
|
||||
val spaceWidth = FontLayoutService.getInstance().charWidth2D(metrics, ' '.code)
|
||||
maximumEditorTextWidth =
|
||||
ceil(spaceWidth * (editor.settings.getRightMargin(editor.project)) - 4).toInt()
|
||||
|
||||
val scrollbarFlip = editor.scrollPane.getClientProperty(JBScrollPane.Flip::class.java)
|
||||
verticalScrollbarFlipped =
|
||||
scrollbarFlip == JBScrollPane.Flip.HORIZONTAL || scrollbarFlip == JBScrollPane.Flip.BOTH
|
||||
}
|
||||
|
||||
override fun componentResized(e: ComponentEvent) = updateWidthForAllInlays()
|
||||
override fun componentHidden(e: ComponentEvent) = updateWidthForAllInlays()
|
||||
override fun componentShown(e: ComponentEvent) = updateWidthForAllInlays()
|
||||
|
||||
private fun updateWidthForAllInlays() {
|
||||
val newWidth = calcWidth()
|
||||
if (editorTextWidth == newWidth) return
|
||||
editorTextWidth = newWidth
|
||||
|
||||
managedInlays.keys.forEach {
|
||||
it.dispatchEvent(ComponentEvent(it, ComponentEvent.COMPONENT_RESIZED))
|
||||
it.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcWidth(): Int {
|
||||
val visibleEditorTextWidth =
|
||||
editor.scrollPane.viewport.width - getVerticalScrollbarWidth() - getGutterTextGap()
|
||||
return min(max(visibleEditorTextWidth, 0), maximumEditorTextWidth)
|
||||
}
|
||||
|
||||
private fun getVerticalScrollbarWidth(): Int {
|
||||
val width = editor.scrollPane.verticalScrollBar.width
|
||||
return if (!verticalScrollbarFlipped) width * 2 else width
|
||||
}
|
||||
|
||||
private fun getGutterTextGap(): Int {
|
||||
return if (verticalScrollbarFlipped) {
|
||||
val gutter = (editor as EditorEx).gutterComponentEx
|
||||
gutter.width - gutter.whitespaceSeparatorOffset
|
||||
} else 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val INLAYS_KEY: Key<EditorComponentInlaysManager> = Key.create("InlineEditInlaysManager")
|
||||
|
||||
fun from(editor: Editor): EditorComponentInlaysManager {
|
||||
return synchronized(editor) {
|
||||
val manager = editor.getUserData(INLAYS_KEY)
|
||||
if (manager == null) {
|
||||
val newManager = EditorComponentInlaysManager(editor as EditorImpl)
|
||||
editor.putUserData(INLAYS_KEY, newManager)
|
||||
newManager
|
||||
} else manager
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,17 +4,17 @@ import com.intellij.openapi.application.runInEdt
|
|||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.Icons
|
||||
import ee.carlrobert.codegpt.ui.EditCodePopover
|
||||
import ee.carlrobert.codegpt.ui.InlineEditPopover
|
||||
import javax.swing.Icon
|
||||
|
||||
open class EditCodeAction(icon: Icon) : BaseEditorAction(icon) {
|
||||
open class InlineEditAction(icon: Icon) : BaseEditorAction(icon) {
|
||||
override fun actionPerformed(project: Project, editor: Editor, selectedText: String) {
|
||||
runInEdt {
|
||||
EditCodePopover(editor).show()
|
||||
InlineEditPopover(editor).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EditCodeFloatingMenuAction : EditCodeAction(Icons.DefaultSmall)
|
||||
class InlineEditFloatingMenuAction : InlineEditAction(Icons.DefaultSmall)
|
||||
|
||||
class EditCodeContextMenuAction : EditCodeAction(Icons.Sparkle)
|
||||
class InlineEditContextMenuAction : InlineEditAction(Icons.Sparkle)
|
||||
|
|
@ -14,9 +14,11 @@ class ShowEditorActionGroupAction : AnAction() {
|
|||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val actionManager = ActionManager.getInstance()
|
||||
val actionGroup = actionManager.getAction("action.editor.group.EditorActionGroup")
|
||||
JBPopupFactory.getInstance().createActionGroupPopup(
|
||||
CodeGPTBundle.get("project.label"), (actionGroup as ActionGroup), e.dataContext,
|
||||
ActionSelectionAid.ALPHA_NUMBERING, true
|
||||
).show(RelativePoint(MouseInfo.getPointerInfo().location))
|
||||
JBPopupFactory.getInstance()
|
||||
.createActionGroupPopup(
|
||||
CodeGPTBundle.get("project.label"), (actionGroup as ActionGroup), e.dataContext,
|
||||
ActionSelectionAid.ALPHA_NUMBERING, true
|
||||
)
|
||||
.show(RelativePoint(MouseInfo.getPointerInfo().location))
|
||||
}
|
||||
}
|
||||
|
|
@ -129,11 +129,17 @@ data class AutoApplyParameters(
|
|||
val featureType: FeatureType = FeatureType.AUTO_APPLY
|
||||
)
|
||||
|
||||
data class EditCodeCompletionParameters(
|
||||
data class InlineEditCompletionParameters(
|
||||
val prompt: String,
|
||||
val selectedText: String,
|
||||
val chatMode: ChatMode = ChatMode.EDIT,
|
||||
val featureType: FeatureType = FeatureType.EDIT_CODE
|
||||
val selectedText: String? = null,
|
||||
val filePath: String? = null,
|
||||
val fileExtension: String? = null,
|
||||
val projectBasePath: String? = null,
|
||||
val referencedFiles: List<ReferencedFile>? = null,
|
||||
val gitDiff: String? = null,
|
||||
val conversation: Conversation? = null,
|
||||
val conversationHistory: List<Conversation>? = null,
|
||||
val diagnosticsInfo: String? = null
|
||||
) : CompletionParameters
|
||||
|
||||
data class ImageDetails(
|
||||
|
|
@ -155,4 +161,4 @@ data class ImageDetails(
|
|||
result = 31 * result + data.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
package ee.carlrobert.codegpt.completions
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
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.FilteredPromptsService
|
||||
import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.util.file.FileUtil
|
||||
import ee.carlrobert.llm.completion.CompletionRequest
|
||||
|
||||
interface CompletionRequestFactory {
|
||||
fun createChatRequest(params: ChatCompletionParameters): CompletionRequest
|
||||
fun createEditCodeRequest(params: EditCodeCompletionParameters): CompletionRequest
|
||||
fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest
|
||||
fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest
|
||||
fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest
|
||||
fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest
|
||||
|
|
@ -49,15 +50,125 @@ abstract class BaseRequestFactory : CompletionRequestFactory {
|
|||
private const val AUTO_APPLY_MAX_TOKENS = 8192
|
||||
private const val DEFAULT_MAX_TOKENS = 4096
|
||||
}
|
||||
override fun createEditCodeRequest(params: EditCodeCompletionParameters): CompletionRequest {
|
||||
val prompt = "Code to modify:\n${params.selectedText}\n\nInstructions: ${params.prompt}"
|
||||
|
||||
data class InlineEditPrompts(val systemPrompt: String, val userPrompt: String)
|
||||
|
||||
protected fun prepareInlineEditPrompts(params: InlineEditCompletionParameters): InlineEditPrompts {
|
||||
val language = params.fileExtension ?: "txt"
|
||||
val filePath = params.filePath ?: "untitled"
|
||||
var systemPrompt =
|
||||
service<PromptsSettings>().state.coreActions.editCode.instructions
|
||||
?: CoreActionsState.DEFAULT_EDIT_CODE_PROMPT
|
||||
|
||||
|
||||
if (params.projectBasePath != null) {
|
||||
val projectContext =
|
||||
"Project Context:\nProject root: ${params.projectBasePath}\nAll file paths should be relative to this project root."
|
||||
systemPrompt = systemPrompt.replace("{{PROJECT_CONTEXT}}", projectContext)
|
||||
} else {
|
||||
systemPrompt = systemPrompt.replace("\n{{PROJECT_CONTEXT}}\n", "")
|
||||
}
|
||||
|
||||
val currentFileContent = try {
|
||||
params.filePath?.let { LocalFileSystem.getInstance().findFileByPath(it)?.readText() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
val currentFileBlock = buildString {
|
||||
append("```$language:$filePath\n")
|
||||
append(currentFileContent ?: "")
|
||||
append("\n```")
|
||||
}
|
||||
systemPrompt = systemPrompt.replace("{{CURRENT_FILE_CONTEXT}}", currentFileBlock)
|
||||
|
||||
val externalContext = buildString {
|
||||
val currentPath = filePath
|
||||
val unique = mutableSetOf<String>()
|
||||
val hasRefs = params.referencedFiles
|
||||
?.filter { it.filePath != currentPath }
|
||||
?.any { !it.fileContent.isNullOrBlank() } == true
|
||||
|
||||
if (hasRefs) {
|
||||
append("\n\n### Referenced Files")
|
||||
params.referencedFiles
|
||||
.filter { it.filePath != currentPath }
|
||||
.forEach {
|
||||
if (!it.fileContent.isNullOrBlank() && unique.add(it.filePath)) {
|
||||
append("\n\n```${it.fileExtension}:${it.filePath}\n")
|
||||
append(it.fileContent)
|
||||
append("\n```")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.gitDiff.isNullOrBlank()) {
|
||||
append("\n\n### Git Diff\n\n")
|
||||
append("```diff\n${params.gitDiff}\n```")
|
||||
}
|
||||
|
||||
if (!params.conversationHistory.isNullOrEmpty()) {
|
||||
append("\n\n### Conversation History\n")
|
||||
params.conversationHistory.forEach { conversation ->
|
||||
conversation.messages.forEach { message ->
|
||||
if (!message.prompt.isNullOrBlank()) {
|
||||
append("\n**User:** ${message.prompt.trim()}")
|
||||
}
|
||||
if (!message.response.isNullOrBlank()) {
|
||||
append("\n**Assistant:** ${message.response.trim()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.diagnosticsInfo.isNullOrBlank()) {
|
||||
append("\n\n### Diagnostics\n")
|
||||
append(params.diagnosticsInfo)
|
||||
}
|
||||
}
|
||||
systemPrompt = if (externalContext.isEmpty()) {
|
||||
systemPrompt.replace(
|
||||
"{{EXTERNAL_CONTEXT}}",
|
||||
"## External Context\n\nNo external context selected."
|
||||
)
|
||||
} else {
|
||||
systemPrompt.replace(
|
||||
"{{EXTERNAL_CONTEXT}}",
|
||||
"## External Context$externalContext"
|
||||
)
|
||||
}
|
||||
|
||||
val userPrompt = buildString {
|
||||
if (!params.selectedText.isNullOrBlank()) {
|
||||
append("Selected code:\n")
|
||||
append("```$language\n")
|
||||
append(params.selectedText)
|
||||
append("\n```\n\n")
|
||||
}
|
||||
append("Request: ${params.prompt}")
|
||||
}
|
||||
|
||||
return InlineEditPrompts(systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
override fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest {
|
||||
val prepared = prepareInlineEditPrompts(params)
|
||||
return createBasicCompletionRequest(
|
||||
service<FilteredPromptsService>().getFilteredEditCodePrompt(params.chatMode), prompt, AUTO_APPLY_MAX_TOKENS, true, FeatureType.EDIT_CODE
|
||||
prepared.systemPrompt,
|
||||
prepared.userPrompt,
|
||||
AUTO_APPLY_MAX_TOKENS,
|
||||
true,
|
||||
FeatureType.INLINE_EDIT
|
||||
)
|
||||
}
|
||||
|
||||
override fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest {
|
||||
return createBasicCompletionRequest(params.systemPrompt, params.gitDiff, 512, true, FeatureType.COMMIT_MESSAGE)
|
||||
return createBasicCompletionRequest(
|
||||
params.systemPrompt,
|
||||
params.gitDiff,
|
||||
512,
|
||||
true,
|
||||
FeatureType.COMMIT_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
override fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest {
|
||||
|
|
@ -74,16 +185,26 @@ abstract class BaseRequestFactory : CompletionRequestFactory {
|
|||
override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest {
|
||||
val destination = params.destination
|
||||
val language = FileUtil.getFileExtension(destination.path)
|
||||
|
||||
|
||||
val formattedSource = CompletionRequestUtil.formatCodeWithLanguage(params.source, language)
|
||||
val formattedDestination = CompletionRequestUtil.formatCode(destination.readText(), destination.path)
|
||||
|
||||
val systemPromptTemplate = service<FilteredPromptsService>().getFilteredAutoApplyPrompt(params.chatMode, params.destination)
|
||||
val formattedDestination =
|
||||
CompletionRequestUtil.formatCode(destination.readText(), destination.path)
|
||||
|
||||
val systemPromptTemplate = service<FilteredPromptsService>().getFilteredAutoApplyPrompt(
|
||||
params.chatMode,
|
||||
params.destination
|
||||
)
|
||||
val systemPrompt = systemPromptTemplate
|
||||
.replace("{{changes_to_merge}}", formattedSource)
|
||||
.replace("{{destination_file}}", formattedDestination)
|
||||
|
||||
return createBasicCompletionRequest(systemPrompt, "Merge the following changes to the destination file.", AUTO_APPLY_MAX_TOKENS, true, FeatureType.AUTO_APPLY)
|
||||
|
||||
return createBasicCompletionRequest(
|
||||
systemPrompt,
|
||||
"Merge the following changes to the destination file.",
|
||||
AUTO_APPLY_MAX_TOKENS,
|
||||
true,
|
||||
FeatureType.AUTO_APPLY
|
||||
)
|
||||
}
|
||||
|
||||
abstract fun createBasicCompletionRequest(
|
||||
|
|
|
|||
|
|
@ -7,16 +7,17 @@ import com.intellij.openapi.vfs.VirtualFile
|
|||
import ee.carlrobert.codegpt.CodeGPTPlugin
|
||||
import ee.carlrobert.codegpt.completions.BaseRequestFactory
|
||||
import ee.carlrobert.codegpt.completions.ChatCompletionParameters
|
||||
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
|
||||
import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory.Companion.buildOpenAIMessages
|
||||
import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer
|
||||
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
|
||||
import ee.carlrobert.codegpt.settings.models.ModelSettings
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
|
||||
import ee.carlrobert.codegpt.util.file.FileUtil
|
||||
import ee.carlrobert.llm.client.codegpt.request.chat.*
|
||||
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage
|
||||
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage
|
||||
|
||||
class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructureSerializer) :
|
||||
BaseRequestFactory() {
|
||||
|
|
@ -130,6 +131,28 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure
|
|||
.build()
|
||||
}
|
||||
|
||||
override fun createInlineEditRequest(params: InlineEditCompletionParameters): ChatCompletionRequest {
|
||||
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.INLINE_EDIT)
|
||||
val prepared = prepareInlineEditPrompts(params)
|
||||
val messages: MutableList<OpenAIChatCompletionMessage> =
|
||||
OpenAIRequestFactory.buildInlineEditMessages(prepared, params.conversation)
|
||||
|
||||
if (model == "o4-mini") {
|
||||
val collapsed = messages.joinToString("\n\n") { msg ->
|
||||
when (msg) {
|
||||
is OpenAIChatCompletionStandardMessage -> msg.content
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
return buildBasicO1Request(model, collapsed, systemPrompt = "", maxCompletionTokens = 4096, stream = true)
|
||||
}
|
||||
|
||||
return ChatCompletionRequest.Builder(messages)
|
||||
.setModel(model)
|
||||
.setStream(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildBasicO1Request(
|
||||
model: String,
|
||||
prompt: String,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
|||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.completions.BaseRequestFactory
|
||||
import ee.carlrobert.codegpt.completions.ChatCompletionParameters
|
||||
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
|
||||
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey
|
||||
import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
|
|
@ -58,6 +59,19 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() {
|
|||
return CustomOpenAIRequest(request)
|
||||
}
|
||||
|
||||
override fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest {
|
||||
val service = service<CustomServicesSettings>().customServiceStateForFeatureType(FeatureType.INLINE_EDIT)
|
||||
val prepared = prepareInlineEditPrompts(params)
|
||||
val messages = OpenAIRequestFactory.buildInlineEditMessages(prepared, params.conversation)
|
||||
val request = buildCustomOpenAIChatCompletionRequest(
|
||||
service.chatCompletionSettings,
|
||||
messages,
|
||||
true,
|
||||
getCredential(CredentialKey.CustomServiceApiKey(service.name.orEmpty()))
|
||||
)
|
||||
return CustomOpenAIRequest(request)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun buildCustomOpenAICompletionRequest(
|
||||
context: String,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package ee.carlrobert.codegpt.completions.factory
|
|||
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.completions.BaseRequestFactory
|
||||
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
|
||||
import ee.carlrobert.codegpt.completions.ChatCompletionParameters
|
||||
import ee.carlrobert.codegpt.completions.ConversationType
|
||||
import ee.carlrobert.codegpt.completions.llama.LlamaModel
|
||||
|
|
@ -12,6 +13,7 @@ import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
|
|||
import ee.carlrobert.codegpt.settings.prompts.addProjectPath
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.llm.client.llama.completion.LlamaCompletionRequest
|
||||
|
||||
class LlamaRequestFactory : BaseRequestFactory() {
|
||||
|
|
@ -52,6 +54,14 @@ class LlamaRequestFactory : BaseRequestFactory() {
|
|||
return buildLlamaRequest(finalPrompt, emptyList(), stream)
|
||||
}
|
||||
|
||||
override fun createInlineEditRequest(params: InlineEditCompletionParameters): LlamaCompletionRequest {
|
||||
val prepared = prepareInlineEditPrompts(params)
|
||||
val promptTemplate = getPromptTemplate(FeatureType.INLINE_EDIT)
|
||||
val history = params.conversation?.messages?.filter { !it.response.isNullOrBlank() } ?: listOf()
|
||||
val finalPrompt = promptTemplate.buildPrompt(prepared.systemPrompt, prepared.userPrompt, history)
|
||||
return buildLlamaRequest(finalPrompt, emptyList(), stream = true)
|
||||
}
|
||||
|
||||
private fun getPromptTemplate(featureType: FeatureType? = null): PromptTemplate {
|
||||
val settings = service<LlamaSettings>().state
|
||||
return if (settings.isUseCustomModel)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import java.io.IOException
|
|||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
class OpenAIRequestFactory : CompletionRequestFactory {
|
||||
class OpenAIRequestFactory : BaseRequestFactory() {
|
||||
|
||||
override fun createChatRequest(params: ChatCompletionParameters): OpenAIChatCompletionRequest {
|
||||
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.CHAT)
|
||||
|
|
@ -47,15 +47,27 @@ class OpenAIRequestFactory : CompletionRequestFactory {
|
|||
return requestBuilder.build()
|
||||
}
|
||||
|
||||
override fun createEditCodeRequest(params: EditCodeCompletionParameters): OpenAIChatCompletionRequest {
|
||||
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.EDIT_CODE)
|
||||
val prompt = "Code to modify:\n${params.selectedText}\n\nInstructions: ${params.prompt}"
|
||||
val systemPrompt =
|
||||
service<FilteredPromptsService>().getFilteredEditCodePrompt(params.chatMode)
|
||||
if (isReasoningModel(model)) {
|
||||
return buildBasicO1Request(model, prompt, systemPrompt, stream = true)
|
||||
override fun createInlineEditRequest(params: InlineEditCompletionParameters): OpenAIChatCompletionRequest {
|
||||
val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.INLINE_EDIT)
|
||||
val prepared = prepareInlineEditPrompts(params)
|
||||
val messages = buildInlineEditMessages(prepared, params.conversation)
|
||||
|
||||
val configuration = service<ConfigurationSettings>().state
|
||||
return if (isReasoningModel(model)) {
|
||||
val collapsed = messages.joinToString("\n\n") { msg ->
|
||||
when (msg) {
|
||||
is OpenAIChatCompletionStandardMessage -> msg.content
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
buildBasicO1Request(model, collapsed, systemPrompt = "", stream = true)
|
||||
} else {
|
||||
OpenAIChatCompletionRequest.Builder(messages)
|
||||
.setModel(model)
|
||||
.setStream(true)
|
||||
.setTemperature(configuration.temperature.toDouble())
|
||||
.build()
|
||||
}
|
||||
return createBasicCompletionRequest(systemPrompt, prompt, model, true)
|
||||
}
|
||||
|
||||
override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest {
|
||||
|
|
@ -78,6 +90,29 @@ class OpenAIRequestFactory : CompletionRequestFactory {
|
|||
return createBasicCompletionRequest(systemPrompt, prompt, model, true)
|
||||
}
|
||||
|
||||
override fun createBasicCompletionRequest(
|
||||
systemPrompt: String,
|
||||
userPrompt: String,
|
||||
maxTokens: Int,
|
||||
stream: Boolean,
|
||||
featureType: FeatureType
|
||||
): CompletionRequest {
|
||||
val model = ModelSelectionService.getInstance().getModelForFeature(featureType)
|
||||
return if (isReasoningModel(model)) {
|
||||
buildBasicO1Request(model, userPrompt, systemPrompt, maxCompletionTokens = maxTokens, stream = stream)
|
||||
} else {
|
||||
OpenAIChatCompletionRequest.Builder(
|
||||
listOf(
|
||||
OpenAIChatCompletionStandardMessage("system", systemPrompt),
|
||||
OpenAIChatCompletionStandardMessage("user", userPrompt)
|
||||
)
|
||||
)
|
||||
.setModel(model)
|
||||
.setStream(stream)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCommitMessageRequest(params: CommitMessageCompletionParameters): OpenAIChatCompletionRequest {
|
||||
val model =
|
||||
ModelSelectionService.getInstance().getModelForFeature(FeatureType.COMMIT_MESSAGE)
|
||||
|
|
@ -106,6 +141,22 @@ class OpenAIRequestFactory : CompletionRequestFactory {
|
|||
}
|
||||
|
||||
companion object {
|
||||
fun buildInlineEditMessages(
|
||||
prepared: InlineEditPrompts,
|
||||
conversation: Conversation?
|
||||
): MutableList<OpenAIChatCompletionMessage> {
|
||||
val messages = mutableListOf<OpenAIChatCompletionMessage>()
|
||||
messages.add(OpenAIChatCompletionStandardMessage("system", prepared.systemPrompt))
|
||||
conversation?.messages?.forEach { m ->
|
||||
val p = m.prompt?.trim().orEmpty()
|
||||
if (p.isNotEmpty()) messages.add(OpenAIChatCompletionStandardMessage("user", p))
|
||||
val r = m.response?.trim().orEmpty()
|
||||
if (r.isNotEmpty()) messages.add(OpenAIChatCompletionStandardMessage("assistant", r))
|
||||
}
|
||||
messages.add(OpenAIChatCompletionStandardMessage("user", prepared.userPrompt))
|
||||
return messages
|
||||
}
|
||||
|
||||
fun isReasoningModel(model: String?) =
|
||||
listOf(
|
||||
O_4_MINI.code,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.util.Key
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
|
||||
/**
|
||||
* Maintains an ephemeral inline-edit conversation (user/assistant turns) per editor.
|
||||
* This mirrors how regular chat builds provider-friendly message histories.
|
||||
*/
|
||||
object InlineEditConversationManager {
|
||||
|
||||
private val CONVERSATION_KEY: Key<Conversation> = Key.create("InlineEditConversation")
|
||||
|
||||
fun getOrCreate(editor: EditorEx): Conversation {
|
||||
val existing = editor.getUserData(CONVERSATION_KEY)
|
||||
if (existing != null) return existing
|
||||
val conversation = Conversation().apply {
|
||||
projectPath = editor.project?.basePath
|
||||
title = "Inline Edit (${editor.virtualFile?.name ?: "untitled"})"
|
||||
}
|
||||
editor.putUserData(CONVERSATION_KEY, conversation)
|
||||
return conversation
|
||||
}
|
||||
|
||||
fun addUserMessage(editor: EditorEx, prompt: String): Message {
|
||||
val message = Message(prompt)
|
||||
getOrCreate(editor).addMessage(message)
|
||||
return message
|
||||
}
|
||||
|
||||
fun addAssistantResponse(message: Message, content: String) {
|
||||
message.response = content
|
||||
}
|
||||
|
||||
fun clear(editor: EditorEx) {
|
||||
editor.putUserData(CONVERSATION_KEY, null)
|
||||
}
|
||||
|
||||
fun moveConversation(source: EditorEx?, target: EditorEx?) {
|
||||
if (source == null || target == null) return
|
||||
val conversation = source.getUserData(CONVERSATION_KEY) ?: return
|
||||
target.putUserData(CONVERSATION_KEY, conversation)
|
||||
source.putUserData(CONVERSATION_KEY, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace
|
||||
|
||||
internal data class FilterResult(
|
||||
val pairs: List<Pair<String, String>>,
|
||||
val filteredCount: Int,
|
||||
val stats: Map<String, Int>
|
||||
)
|
||||
|
||||
internal object InlineEditFilter {
|
||||
|
||||
fun filterSegments(
|
||||
currentPath: String?,
|
||||
fileName: String?,
|
||||
fileExt: String?,
|
||||
segments: List<SearchReplace>
|
||||
): FilterResult {
|
||||
fun fileMatches(path: String?): Boolean {
|
||||
if (path.isNullOrBlank()) return true
|
||||
if (currentPath == null) return false
|
||||
val name = fileName ?: ""
|
||||
return path == currentPath || path.endsWith("/" + name) || path == name
|
||||
}
|
||||
|
||||
val accepted = mutableListOf<Pair<String, String>>()
|
||||
var filtered = 0
|
||||
val reasons = mutableMapOf<String, Int>()
|
||||
fun bump(reason: String) {
|
||||
reasons[reason] = (reasons[reason] ?: 0) + 1
|
||||
}
|
||||
|
||||
for (seg in segments) {
|
||||
val search = seg.search
|
||||
val replace = seg.replace
|
||||
|
||||
if (!fileMatches(seg.filePath)) {
|
||||
filtered++; bump("wrong-file"); continue
|
||||
}
|
||||
|
||||
val normalizedSearch = search.trim()
|
||||
if (normalizedSearch.isBlank()) {
|
||||
filtered++; bump("empty-search"); continue
|
||||
}
|
||||
|
||||
accepted.add(normalizedSearch to replace)
|
||||
}
|
||||
|
||||
return FilterResult(accepted, filtered, reasons)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,582 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.KeyboardShortcut
|
||||
import com.intellij.openapi.actionSystem.Shortcut
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.editor.colors.EditorColorsManager
|
||||
import com.intellij.openapi.editor.colors.EditorFontType
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.editor.markup.HighlighterLayer
|
||||
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
||||
import com.intellij.openapi.editor.markup.RangeHighlighter
|
||||
import com.intellij.openapi.editor.markup.TextAttributes
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory
|
||||
import com.intellij.openapi.keymap.KeymapManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import ee.carlrobert.codegpt.actions.editor.EditorComponentInlaysManager
|
||||
import ee.carlrobert.codegpt.ui.InlineEditPopover
|
||||
import ee.carlrobert.codegpt.ui.components.InlineEditChips
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Insets
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JTextPane
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.text.SimpleAttributeSet
|
||||
import javax.swing.text.StyleConstants
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
class InlineEditInlayRenderer(
|
||||
private val editor: EditorEx,
|
||||
private val project: Project
|
||||
) : Disposable {
|
||||
private val logger = Logger.getInstance(InlineEditInlayRenderer::class.java)
|
||||
private var interactive: Boolean = true
|
||||
|
||||
data class InlineChange(
|
||||
val startOffset: Int,
|
||||
val endOffset: Int,
|
||||
val oldText: String,
|
||||
val newText: String,
|
||||
var deletionHighlighter: RangeHighlighter? = null,
|
||||
var additionInlay: Disposable? = null,
|
||||
var buttonInlay: Disposable? = null,
|
||||
var isAccepted: Boolean = false,
|
||||
var isRejected: Boolean = false
|
||||
)
|
||||
|
||||
private val changes = mutableListOf<InlineChange>()
|
||||
private val allHighlighters = mutableListOf<RangeHighlighter>()
|
||||
|
||||
private data class HunkUI(
|
||||
val hunk: InlineEditSession.Hunk,
|
||||
var deletionHighlighter: RangeHighlighter? = null,
|
||||
var additionInlay: Disposable? = null,
|
||||
var buttonInlay: Disposable? = null,
|
||||
)
|
||||
|
||||
private val hunkUIs = mutableListOf<HunkUI>()
|
||||
|
||||
fun renderHunks(hunks: List<InlineEditSession.Hunk>) {
|
||||
runInEdt {
|
||||
hunks.forEach { renderHunk(it) }
|
||||
showTopPanel()
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceHunks(hunks: List<InlineEditSession.Hunk>) {
|
||||
runInEdt {
|
||||
val prev = hunkUIs.toList()
|
||||
prev.forEach { removeHunkUI(it) }
|
||||
hunks.forEach { renderHunk(it) }
|
||||
showTopPanel()
|
||||
}
|
||||
}
|
||||
|
||||
private var topPanelDisposable: Disposable? = null
|
||||
|
||||
private fun showTopPanel() {
|
||||
topPanelDisposable?.dispose()
|
||||
topPanelDisposable = null
|
||||
}
|
||||
|
||||
fun setInteractive(enabled: Boolean) {
|
||||
interactive = enabled
|
||||
}
|
||||
|
||||
private fun renderHunk(hunk: InlineEditSession.Hunk) {
|
||||
val start = hunk.baseMarker.startOffset
|
||||
val end = hunk.baseMarker.endOffset
|
||||
val baseLen = (end - start).coerceAtLeast(0)
|
||||
|
||||
val deletion = if (baseLen > 0) highlightDeletion(start, end) else null
|
||||
|
||||
val hasNew = hunk.proposedSlice.isNotBlank()
|
||||
val showAbove = if (baseLen > 0 && hasNew) true else baseLen == 0
|
||||
val insertionOffset = if (baseLen == 0) start else end
|
||||
val addition = if (hasNew) addInlayForAddition(
|
||||
insertionOffset,
|
||||
hunk.proposedSlice,
|
||||
showAbove = showAbove,
|
||||
onAccept = {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
?.accept(hunk)
|
||||
},
|
||||
onReject = {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
?.reject(hunk)
|
||||
}
|
||||
) else null
|
||||
|
||||
val header =
|
||||
if (!hasNew && baseLen > 0) addInlineButtons(start, hunk) else null
|
||||
|
||||
hunkUIs.add(HunkUI(hunk, deletion, addition, header))
|
||||
}
|
||||
|
||||
private fun highlightDeletion(startOffset: Int, endOffset: Int): RangeHighlighter? {
|
||||
try {
|
||||
val doc = editor.document
|
||||
val boundedStart = startOffset.coerceIn(0, doc.textLength)
|
||||
val inclusiveEnd =
|
||||
(endOffset - 1).coerceAtLeast(boundedStart).coerceAtMost(doc.textLength - 1)
|
||||
|
||||
val startLine = doc.getLineNumber(boundedStart)
|
||||
val endLine = doc.getLineNumber(inclusiveEnd)
|
||||
val lineStart = doc.getLineStartOffset(startLine)
|
||||
val lineEnd = doc.getLineEndOffset(endLine)
|
||||
|
||||
val attributes = TextAttributes().apply {
|
||||
backgroundColor = JBColor(
|
||||
Color(255, 220, 220, 60),
|
||||
Color(80, 40, 40, 80)
|
||||
)
|
||||
foregroundColor = null
|
||||
effectType = null
|
||||
effectColor = null
|
||||
}
|
||||
|
||||
val highlighter = editor.markupModel.addRangeHighlighter(
|
||||
lineStart,
|
||||
lineEnd,
|
||||
HighlighterLayer.SELECTION - 1,
|
||||
attributes,
|
||||
HighlighterTargetArea.LINES_IN_RANGE
|
||||
)
|
||||
|
||||
allHighlighters.add(highlighter)
|
||||
return highlighter
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error creating deletion highlight", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun addInlayForAddition(
|
||||
offset: Int,
|
||||
newText: String,
|
||||
showAbove: Boolean = true,
|
||||
onAccept: (() -> Unit)? = null,
|
||||
onReject: (() -> Unit)? = null,
|
||||
): Disposable? {
|
||||
try {
|
||||
val inlaysManager = EditorComponentInlaysManager.Companion.from(editor)
|
||||
val leftInset = computeLeftInsetForOffset(offset)
|
||||
val component = createAdditionComponent(newText, onAccept, onReject, leftInset)
|
||||
val lineNumber = editor.document.getLineNumber(offset)
|
||||
return inlaysManager.insert(lineNumber, component, showAbove)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error creating addition inlay", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAdditionComponent(
|
||||
text: String,
|
||||
onAccept: (() -> Unit)?,
|
||||
onReject: (() -> Unit)?,
|
||||
leftInset: Int,
|
||||
): JComponent {
|
||||
val displayText = text.trimEnd('\n', '\r')
|
||||
|
||||
val panel = JPanel(BorderLayout()).apply {
|
||||
isOpaque = true
|
||||
background = JBColor(Color(0, 128, 0, 28), Color(0, 128, 0, 36))
|
||||
border = JBUI.Borders.empty(0, leftInset, 0, 0)
|
||||
}
|
||||
|
||||
if (onAccept != null || onReject != null) {
|
||||
val header = JPanel(FlowLayout(FlowLayout.RIGHT, 6, 0)).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
fun badge(textLabel: String, bg: Color, onClick: () -> Unit): JComponent {
|
||||
return object : JComponent() {
|
||||
init {
|
||||
cursor = Cursor(Cursor.HAND_CURSOR)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent?) {
|
||||
onClick()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val fm = getFontMetrics(font)
|
||||
val w = max(34, fm.stringWidth(textLabel) + 14)
|
||||
return Dimension(w, 18)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g as Graphics2D
|
||||
g2.setRenderingHint(
|
||||
RenderingHints.KEY_ANTIALIASING,
|
||||
RenderingHints.VALUE_ANTIALIAS_ON
|
||||
)
|
||||
g2.color = Color(bg.red, bg.green, bg.blue, 200)
|
||||
g2.fillRoundRect(0, 0, width - 1, height - 1, 8, 8)
|
||||
g2.color = Color.WHITE
|
||||
val fm = g2.fontMetrics
|
||||
val tx = (width - fm.stringWidth(textLabel)) / 2
|
||||
val ty = (height - fm.height) / 2 + fm.ascent
|
||||
g2.drawString(textLabel, tx, ty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val acceptLabel = formatShortcutLabel(
|
||||
actionId = "CodeGPT.AcceptCurrentInlineEdit",
|
||||
fallback = if (SystemInfo.isMac) "⌘Y" else "Ctrl+Y"
|
||||
)
|
||||
val rejectLabel = formatShortcutLabel(
|
||||
actionId = "CodeGPT.RejectCurrentInlineEdit",
|
||||
fallback = if (SystemInfo.isMac) "⌘N" else "Ctrl+N"
|
||||
)
|
||||
|
||||
onAccept?.let { header.add(badge(acceptLabel, Color(0, 153, 0), it)) }
|
||||
onReject?.let {
|
||||
header.add(
|
||||
badge(
|
||||
rejectLabel,
|
||||
JBColor(Color(0xD0, 0x36, 0x36), Color(0xD0, 0x36, 0x36)),
|
||||
it
|
||||
)
|
||||
)
|
||||
}
|
||||
panel.add(header, BorderLayout.NORTH)
|
||||
}
|
||||
|
||||
val textPane = JTextPane().apply {
|
||||
isEditable = false
|
||||
isOpaque = false
|
||||
font = editor.colorsScheme.getFont(EditorFontType.PLAIN)
|
||||
foreground = editor.colorsScheme.defaultForeground
|
||||
border = null
|
||||
margin = Insets(0, 0, 0, 0)
|
||||
}
|
||||
applySyntaxColors(displayText, textPane)
|
||||
panel.add(textPane, BorderLayout.CENTER)
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun formatShortcutLabel(actionId: String, fallback: String): String {
|
||||
return try {
|
||||
val keymap = KeymapManager.getInstance().activeKeymap
|
||||
val shortcuts = keymap.getShortcuts(actionId)
|
||||
val preferred = when (actionId) {
|
||||
"CodeGPT.AcceptCurrentInlineEdit" -> preferredShortcut(shortcuts, KeyEvent.VK_Y)
|
||||
"CodeGPT.RejectCurrentInlineEdit" -> preferredShortcut(shortcuts, KeyEvent.VK_N)
|
||||
else -> shortcuts.firstOrNull()
|
||||
}
|
||||
val ks = (preferred as? KeyboardShortcut)?.firstKeyStroke
|
||||
if (ks != null) keyStrokeToLabel(ks) else fallback
|
||||
} catch (_: Exception) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun preferredShortcut(shortcuts: Array<Shortcut>, keyCode: Int): Shortcut? {
|
||||
val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK)
|
||||
val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK)
|
||||
return shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs }
|
||||
?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs }
|
||||
?: shortcuts.firstOrNull()
|
||||
}
|
||||
|
||||
private fun keyStrokeToLabel(ks: KeyStroke): String {
|
||||
return if (SystemInfo.isMac) {
|
||||
buildString {
|
||||
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) append('⌘')
|
||||
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) append('⇧')
|
||||
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) append('⌥')
|
||||
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) append('⌃')
|
||||
append(KeyEvent.getKeyText(ks.keyCode).uppercase())
|
||||
}
|
||||
} else {
|
||||
val parts = mutableListOf<String>()
|
||||
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) parts.add("Ctrl")
|
||||
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) parts.add("Shift")
|
||||
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) parts.add("Alt")
|
||||
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) parts.add("Meta")
|
||||
parts.add(KeyEvent.getKeyText(ks.keyCode).uppercase())
|
||||
parts.joinToString("+")
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySyntaxColors(text: String, pane: JTextPane) {
|
||||
val fileType = editor.virtualFile?.fileType
|
||||
if (fileType == null) {
|
||||
pane.text = text; return
|
||||
}
|
||||
val highlighter =
|
||||
SyntaxHighlighterFactory.getSyntaxHighlighter(fileType, project, editor.virtualFile)
|
||||
val lexer = highlighter?.highlightingLexer ?: return
|
||||
lexer.start(text)
|
||||
val doc = pane.styledDocument
|
||||
val scheme = EditorColorsManager.getInstance().globalScheme
|
||||
while (lexer.tokenType != null) {
|
||||
val start = lexer.tokenStart
|
||||
val end = lexer.tokenEnd
|
||||
val segment = text.substring(start, end)
|
||||
val keys = highlighter.getTokenHighlights(lexer.tokenType)
|
||||
val attrs = keys.firstOrNull()?.let { scheme.getAttributes(it) }
|
||||
val style = SimpleAttributeSet()
|
||||
val fg = attrs?.foregroundColor ?: editor.colorsScheme.defaultForeground
|
||||
StyleConstants.setForeground(style, fg)
|
||||
StyleConstants.setFontFamily(
|
||||
style,
|
||||
editor.colorsScheme.getFont(EditorFontType.PLAIN).family
|
||||
)
|
||||
StyleConstants.setFontSize(
|
||||
style,
|
||||
editor.colorsScheme.getFont(EditorFontType.PLAIN).size
|
||||
)
|
||||
doc.insertString(doc.length, segment, style)
|
||||
lexer.advance()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addInlineButtons(
|
||||
offset: Int,
|
||||
hunk: InlineEditSession.Hunk,
|
||||
): Disposable? {
|
||||
if (!interactive) return null
|
||||
try {
|
||||
val inlaysManager = EditorComponentInlaysManager.Companion.from(editor)
|
||||
val leftInset = computeLeftInsetForOffset(offset)
|
||||
val panel = createButtonPanel(hunk, leftInset)
|
||||
val lineNumber = editor.document.getLineNumber(offset)
|
||||
return inlaysManager.insert(lineNumber, panel, true)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error creating hunk button inlay", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun createButtonPanel(
|
||||
hunk: InlineEditSession.Hunk,
|
||||
leftInset: Int = 0
|
||||
): JComponent {
|
||||
val container = JPanel(BorderLayout()).apply {
|
||||
isOpaque = background != null
|
||||
border = JBUI.Borders.empty(0, leftInset, 0, 0)
|
||||
}
|
||||
val row = JPanel(FlowLayout(FlowLayout.RIGHT, 6, 0)).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(0, 8, 0, 0)
|
||||
}
|
||||
val accept = InlineEditChips.keyY {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.accept(hunk)
|
||||
}
|
||||
val reject = InlineEditChips.keyN {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.reject(hunk)
|
||||
}
|
||||
row.add(accept)
|
||||
row.add(reject)
|
||||
container.add(row, BorderLayout.EAST)
|
||||
return container
|
||||
}
|
||||
|
||||
private fun computeLeftInsetForOffset(offset: Int): Int {
|
||||
return try {
|
||||
val line = editor.document.getLineNumber(offset)
|
||||
val lineStart = editor.document.getLineStartOffset(line)
|
||||
val x = editor.offsetToXY(lineStart).x
|
||||
val gutter = editor.gutterComponentEx.width
|
||||
(x - gutter).coerceAtLeast(0)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error computing left inset for offset $offset", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptChange(change: InlineChange) {
|
||||
if (change.isAccepted || change.isRejected) return
|
||||
|
||||
runInEdt {
|
||||
WriteCommandAction.runWriteCommandAction(
|
||||
project,
|
||||
"Accept Inline Edit Change",
|
||||
"InlineEdit",
|
||||
{
|
||||
try {
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.markChangesAsAccepted()
|
||||
editor.document.replaceString(
|
||||
change.startOffset,
|
||||
change.endOffset,
|
||||
change.newText
|
||||
)
|
||||
change.copy(isAccepted = true)
|
||||
removeChangeVisuals(change)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error accepting change", e)
|
||||
}
|
||||
})
|
||||
if (changes.isEmpty()) {
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.setInlineEditControlsVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rejectChange(change: InlineChange) {
|
||||
if (change.isAccepted || change.isRejected) return
|
||||
|
||||
runInEdt {
|
||||
change.copy(isRejected = true)
|
||||
removeChangeVisuals(change)
|
||||
if (changes.isEmpty()) {
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.setInlineEditControlsVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeHunkUI(ui: HunkUI) {
|
||||
ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) }
|
||||
ui.additionInlay?.dispose()
|
||||
ui.buttonInlay?.dispose()
|
||||
hunkUIs.remove(ui)
|
||||
}
|
||||
|
||||
private fun removeChangeVisuals(change: InlineChange) {
|
||||
change.deletionHighlighter?.let { highlighter ->
|
||||
editor.markupModel.removeHighlighter(highlighter)
|
||||
allHighlighters.remove(highlighter)
|
||||
}
|
||||
|
||||
change.additionInlay?.dispose()
|
||||
change.buttonInlay?.dispose()
|
||||
|
||||
changes.remove(change)
|
||||
}
|
||||
|
||||
fun acceptAll() {
|
||||
val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
if (session != null) {
|
||||
session.acceptAll()
|
||||
return
|
||||
}
|
||||
val changesToAccept = changes.filter { !it.isAccepted && !it.isRejected }
|
||||
.sortedByDescending { it.startOffset }
|
||||
changesToAccept.forEach { acceptChange(it) }
|
||||
}
|
||||
|
||||
fun rejectAll() {
|
||||
val session =
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
if (session != null) {
|
||||
session.rejectAll()
|
||||
return
|
||||
}
|
||||
val changesToReject = changes.filter { !it.isAccepted && !it.isRejected }
|
||||
changesToReject.forEach { rejectChange(it) }
|
||||
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.triggerPromptRestoration()
|
||||
}
|
||||
|
||||
fun acceptNext() {
|
||||
val session =
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
if (session != null) {
|
||||
session.acceptNearestToCaret()
|
||||
return
|
||||
}
|
||||
val nextChange = changes
|
||||
.filter { !it.isAccepted && !it.isRejected }
|
||||
.minByOrNull { it.startOffset }
|
||||
nextChange?.let { acceptChange(it) }
|
||||
}
|
||||
|
||||
fun rejectNext() {
|
||||
val session =
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
if (session != null) {
|
||||
val caret = editor.caretModel.offset
|
||||
val pending = hunkUIs.minByOrNull { abs(it.hunk.startOffset - caret) }
|
||||
pending?.let {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.reject(it.hunk)
|
||||
}
|
||||
return
|
||||
}
|
||||
val nextChange = changes
|
||||
.filter { !it.isAccepted && !it.isRejected }
|
||||
.minByOrNull { it.startOffset }
|
||||
nextChange?.let { rejectChange(it) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
runInEdt {
|
||||
disposeAllHighlighters()
|
||||
disposeChanges()
|
||||
disposeHunkUIs()
|
||||
disposeTopPanel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun disposeAllHighlighters() {
|
||||
allHighlighters.forEach { highlighter ->
|
||||
try {
|
||||
editor.markupModel.removeHighlighter(highlighter)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error removing highlighter during disposal", e)
|
||||
}
|
||||
}
|
||||
allHighlighters.clear()
|
||||
}
|
||||
|
||||
private fun disposeChanges() {
|
||||
changes.forEach { change ->
|
||||
try {
|
||||
change.additionInlay?.dispose()
|
||||
change.buttonInlay?.dispose()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error disposing change inlays during disposal", e)
|
||||
}
|
||||
}
|
||||
changes.clear()
|
||||
}
|
||||
|
||||
private fun disposeHunkUIs() {
|
||||
hunkUIs.toList().forEach { ui ->
|
||||
try {
|
||||
ui.additionInlay?.dispose()
|
||||
ui.buttonInlay?.dispose()
|
||||
ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error disposing hunk UI during disposal", e)
|
||||
}
|
||||
}
|
||||
hunkUIs.clear()
|
||||
}
|
||||
|
||||
private fun disposeTopPanel() {
|
||||
try {
|
||||
topPanelDisposable?.dispose()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error disposing top panel", e)
|
||||
}
|
||||
topPanelDisposable = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import com.intellij.ide.IdeEventQueue
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.KeyboardShortcut
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.keymap.KeymapManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import java.awt.AWTEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
/**
|
||||
* Editor-scoped key dispatcher to reliably intercept Cmd/Ctrl+Y and Cmd/Ctrl+N
|
||||
* for Inline Edit while the session is active and the editor has focus.
|
||||
*/
|
||||
class InlineEditKeyEventDispatcher(
|
||||
private val project: Project,
|
||||
private val editor: EditorEx,
|
||||
private val onAccept: () -> Unit,
|
||||
private val onReject: () -> Unit,
|
||||
) : IdeEventQueue.EventDispatcher, Disposable {
|
||||
|
||||
override fun dispatch(e: AWTEvent): Boolean {
|
||||
if (e !is KeyEvent) return false
|
||||
if (e.id != KeyEvent.KEY_PRESSED) return false
|
||||
|
||||
val selected = FileEditorManager.getInstance(project).selectedTextEditor
|
||||
if (selected !== editor) return false
|
||||
|
||||
val ks = KeyStroke.getKeyStrokeForEvent(e)
|
||||
val (acceptKeys, rejectKeys) = currentInlineEditKeystrokes()
|
||||
if (rejectKeys.contains(ks)) { onReject(); e.consume(); return true }
|
||||
if (acceptKeys.contains(ks)) { onAccept(); e.consume(); return true }
|
||||
return false
|
||||
}
|
||||
|
||||
fun register(parent: Disposable) {
|
||||
IdeEventQueue.Companion.getInstance().addDispatcher(this, parent)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
}
|
||||
|
||||
private fun currentInlineEditKeystrokes(): Pair<Set<KeyStroke>, Set<KeyStroke>> {
|
||||
val km = KeymapManager.getInstance().activeKeymap
|
||||
fun firstKeyStrokes(actionId: String): Set<KeyStroke> =
|
||||
km.getShortcuts(actionId)
|
||||
.mapNotNull { (it as? KeyboardShortcut)?.firstKeyStroke }
|
||||
.toSet()
|
||||
|
||||
val accept = firstKeyStrokes("CodeGPT.AcceptCurrentInlineEdit")
|
||||
val reject = firstKeyStrokes("CodeGPT.RejectCurrentInlineEdit")
|
||||
return Pair(accept, reject)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import com.intellij.openapi.actionSystem.*
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts for Inline Edit diff feature.
|
||||
*/
|
||||
class AcceptAllInlineEditAction : AnAction() {
|
||||
init {
|
||||
val keyStroke = KeyStroke.getKeyStroke(
|
||||
KeyEvent.VK_ENTER,
|
||||
InputEvent.META_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK
|
||||
)
|
||||
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
|
||||
val session =
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
if (session != null) {
|
||||
session.acceptAll()
|
||||
} else {
|
||||
acceptAll(editor)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val project = e.project
|
||||
val editor = e.getData(CommonDataKeys.EDITOR)
|
||||
|
||||
e.presentation.isEnabledAndVisible =
|
||||
project != null && editor != null &&
|
||||
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|
||||
|| hasActiveInlineEdit(editor))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun acceptAll(editor: Editor) {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.acceptAll()
|
||||
?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptAll()
|
||||
}
|
||||
|
||||
fun hasActiveInlineEdit(editor: Editor): Boolean =
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null ||
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|
||||
}
|
||||
}
|
||||
|
||||
class RejectAllInlineEditAction : AnAction() {
|
||||
init {
|
||||
val keyStroke = KeyStroke.getKeyStroke(
|
||||
KeyEvent.VK_BACK_SPACE,
|
||||
InputEvent.META_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK
|
||||
)
|
||||
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
|
||||
val session =
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
if (session != null) {
|
||||
session.rejectAll()
|
||||
} else {
|
||||
rejectAll(editor)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val project = e.project
|
||||
val editor = e.getData(CommonDataKeys.EDITOR)
|
||||
|
||||
e.presentation.isEnabledAndVisible = project != null && editor != null
|
||||
&& AcceptAllInlineEditAction.hasActiveInlineEdit(editor)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun rejectAll(editor: Editor) {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.rejectAll()
|
||||
?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RejectInlineEditAction : AnAction() {
|
||||
init {
|
||||
val keyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0)
|
||||
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectNext()
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val project = e.project
|
||||
val editor = e.getData(CommonDataKeys.EDITOR)
|
||||
|
||||
e.presentation.isEnabledAndVisible = project != null && editor != null &&
|
||||
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|
||||
|| AcceptAllInlineEditAction.hasActiveInlineEdit(editor))
|
||||
}
|
||||
}
|
||||
|
||||
class AcceptCurrentInlineEditAction : AnAction() {
|
||||
init {
|
||||
val keyStroke = KeyStroke.getKeyStroke(
|
||||
KeyEvent.VK_ENTER,
|
||||
InputEvent.META_DOWN_MASK
|
||||
)
|
||||
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptNext()
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val project = e.project
|
||||
val editor = e.getData(CommonDataKeys.EDITOR)
|
||||
|
||||
e.presentation.isEnabledAndVisible = project != null && editor != null &&
|
||||
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|
||||
|| AcceptAllInlineEditAction.hasActiveInlineEdit(editor))
|
||||
}
|
||||
}
|
||||
|
||||
class RejectCurrentInlineEditAction : AnAction() {
|
||||
init {
|
||||
val keyStroke = KeyStroke.getKeyStroke(
|
||||
KeyEvent.VK_BACK_SPACE,
|
||||
InputEvent.META_DOWN_MASK
|
||||
)
|
||||
shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null))
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectNext()
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val project = e.project
|
||||
val editor = e.getData(CommonDataKeys.EDITOR)
|
||||
|
||||
e.presentation.isEnabledAndVisible = project != null && editor != null &&
|
||||
(editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null
|
||||
|| AcceptAllInlineEditAction.hasActiveInlineEdit(editor))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,842 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import com.intellij.diff.DiffManager
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.BrowserUtil
|
||||
import com.intellij.notification.NotificationAction
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.editor.ScrollType
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.editor.markup.*
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.testFramework.LightVirtualFile
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.Alarm
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
|
||||
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.SseMessageParser
|
||||
import ee.carlrobert.codegpt.ui.InlineEditPopover
|
||||
import ee.carlrobert.codegpt.ui.ObservableProperties
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import ee.carlrobert.codegpt.util.EditorDiffUtil
|
||||
import ee.carlrobert.llm.client.openai.completion.ErrorDetails
|
||||
import ee.carlrobert.llm.completion.CompletionEventListener
|
||||
import okhttp3.sse.EventSource
|
||||
import java.awt.Color
|
||||
import java.awt.Font
|
||||
import java.util.*
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JLabel
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
/**
|
||||
* Simplified completion listener for Inline Edit feature that handles SEARCH/REPLACE blocks
|
||||
* for the current editor file only.
|
||||
*/
|
||||
class InlineEditSearchReplaceListener(
|
||||
private val editor: EditorEx,
|
||||
private val observableProperties: ObservableProperties,
|
||||
private val selectionTextRange: TextRange,
|
||||
private val requestId: Long,
|
||||
private val userPrompt: String
|
||||
) : CompletionEventListener<String> {
|
||||
|
||||
private val project: Project = editor.project!!
|
||||
private val logger = Logger.getInstance(InlineEditSearchReplaceListener::class.java)
|
||||
private val sseMessageParser = SseMessageParser()
|
||||
private val accumulatedSearchReplaceSegments = mutableListOf<SearchReplace>()
|
||||
private var isStreamingComplete = false
|
||||
private var previewSessionStarted = false
|
||||
private var hasReceivedMessage = false
|
||||
|
||||
private val searchHighlighters = mutableListOf<RangeHighlighter>()
|
||||
private var currentSearchPattern: String? = null
|
||||
private val highlightDebounceAlarm = Alarm()
|
||||
private var hintComponent: JComponent? = null
|
||||
private val waitingAlarm = Alarm()
|
||||
|
||||
private val SEARCH_HIGHLIGHT_COLOR = JBColor(
|
||||
Color(255, 235, 59, 80),
|
||||
Color(255, 235, 59, 60)
|
||||
)
|
||||
|
||||
private val REPLACE_READY_COLOR = JBColor(
|
||||
Color(59, 255, 149, 80),
|
||||
Color(59, 255, 149, 60)
|
||||
)
|
||||
|
||||
enum class HighlightState {
|
||||
SEARCHING,
|
||||
FOUND,
|
||||
REPLACING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
sealed class ValidationResult {
|
||||
object Success : ValidationResult()
|
||||
data class Error(val message: String) : ValidationResult()
|
||||
}
|
||||
|
||||
private fun validateSearchReplacePattern(search: String, replace: String): ValidationResult {
|
||||
val searchText = search.trim()
|
||||
val replaceText = replace.trim()
|
||||
|
||||
if (searchText == replaceText) {
|
||||
return ValidationResult.Error("No changes: search and replace content are identical")
|
||||
}
|
||||
|
||||
if (searchText.isEmpty()) {
|
||||
return ValidationResult.Error("Empty search pattern")
|
||||
}
|
||||
|
||||
return ValidationResult.Success
|
||||
}
|
||||
|
||||
init {
|
||||
waitingAlarm.addRequest({
|
||||
if (!hasReceivedMessage) {
|
||||
showInlineHint(CodeGPTBundle.get("inlineEdit.status.waiting"))
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
private fun deduplicateSearchReplaceBlocks(blocks: List<Pair<String, String>>): List<Pair<String, String>> {
|
||||
val deduplicated = mutableListOf<Pair<String, String>>()
|
||||
val seenPatterns = mutableSetOf<String>()
|
||||
|
||||
for ((search, replace) in blocks) {
|
||||
val normalizedSearch = search.trim()
|
||||
|
||||
if (seenPatterns.contains(normalizedSearch)) continue
|
||||
|
||||
if (normalizedSearch == replace.trim()) continue
|
||||
|
||||
val isSubsumed = deduplicated.any { (existingSearch, _) ->
|
||||
existingSearch.contains(normalizedSearch) || normalizedSearch.contains(
|
||||
existingSearch.trim()
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSubsumed) {
|
||||
deduplicated.add(Pair(search, replace))
|
||||
seenPatterns.add(normalizedSearch)
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
private fun applySimpleSearchReplace(
|
||||
originalContent: String,
|
||||
searchReplaceBlocks: List<Pair<String, String>>
|
||||
): String {
|
||||
val deduplicatedBlocks = deduplicateSearchReplaceBlocks(searchReplaceBlocks)
|
||||
var currentContent = originalContent
|
||||
var totalReplacements = 0
|
||||
|
||||
val docEol = if (originalContent.contains("\r\n")) "\r\n" else "\n"
|
||||
|
||||
for ((search, replace) in deduplicatedBlocks) {
|
||||
val searchText = search.trim().replace("\r\n", "\n").replace("\n", docEol)
|
||||
val replaceText = replace.trim().replace("\r\n", "\n").replace("\n", docEol)
|
||||
|
||||
if (searchText.isEmpty() && originalContent.isNotEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
var replacementCount =
|
||||
if (searchText.isEmpty()) 0 else currentContent.split(searchText).size - 1
|
||||
|
||||
if (replacementCount == 0 && search.contains("...")) {
|
||||
val searchStart = searchText.lines().firstOrNull()?.trim() ?: ""
|
||||
if (searchStart.isNotEmpty() && currentContent.contains(searchStart)) {
|
||||
val startIndex = currentContent.indexOf(searchStart)
|
||||
if (startIndex >= 0) {
|
||||
val lines = currentContent.split(docEol)
|
||||
val startLine =
|
||||
currentContent.substring(0, startIndex).split(docEol).size - 1
|
||||
val searchLines = searchText.split(docEol).size
|
||||
val endLine = minOf(startLine + searchLines, lines.size)
|
||||
|
||||
val actualPattern = lines.subList(startLine, endLine).joinToString(docEol)
|
||||
|
||||
if (currentContent.contains(actualPattern)) {
|
||||
currentContent = currentContent.replace(actualPattern, replaceText)
|
||||
totalReplacements++
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replacementCount == 0) {
|
||||
val tokens = searchText.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
if (tokens.isNotEmpty()) {
|
||||
val pattern = tokens.joinToString("\\s+") { Regex.escape(it) }
|
||||
val regex =
|
||||
Regex(pattern, setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.MULTILINE))
|
||||
val match = regex.find(currentContent)
|
||||
if (match != null) {
|
||||
currentContent = currentContent.replaceRange(match.range, replaceText)
|
||||
totalReplacements++
|
||||
continue
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
currentContent = currentContent.replace(searchText, replaceText)
|
||||
totalReplacements += replacementCount
|
||||
}
|
||||
|
||||
return currentContent
|
||||
}
|
||||
|
||||
private fun isCurrentRequest(): Boolean {
|
||||
val keyValue = editor.getUserData(REQUEST_ID_KEY)
|
||||
return keyValue == null || keyValue == requestId
|
||||
}
|
||||
|
||||
private fun clearAllHighlights() {
|
||||
searchHighlighters.forEach {
|
||||
editor.markupModel.removeHighlighter(it)
|
||||
}
|
||||
searchHighlighters.clear()
|
||||
}
|
||||
|
||||
private fun createHighlighter(
|
||||
range: TextRange,
|
||||
color: JBColor,
|
||||
tooltip: String
|
||||
): RangeHighlighter = editor.markupModel.addRangeHighlighter(
|
||||
range.startOffset,
|
||||
range.endOffset,
|
||||
HighlighterLayer.SELECTION,
|
||||
TextAttributes().apply {
|
||||
backgroundColor = color
|
||||
effectType = EffectType.ROUNDED_BOX
|
||||
effectColor = color.darker()
|
||||
},
|
||||
HighlighterTargetArea.EXACT_RANGE
|
||||
).apply {
|
||||
errorStripeTooltip = tooltip
|
||||
}
|
||||
|
||||
private fun ensureVisible(offset: Int) {
|
||||
val logicalPosition = editor.offsetToLogicalPosition(offset)
|
||||
editor.scrollingModel.scrollTo(logicalPosition, ScrollType.MAKE_VISIBLE)
|
||||
}
|
||||
|
||||
private fun targetRange(): TextRange {
|
||||
return if (selectionTextRange.startOffset < selectionTextRange.endOffset) {
|
||||
selectionTextRange
|
||||
} else {
|
||||
TextRange(0, editor.document.textLength)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(message: String, eventSource: EventSource) {
|
||||
if (!isCurrentRequest()) return
|
||||
hasReceivedMessage = true
|
||||
|
||||
sseMessageParser.parse(message).forEachIndexed { _, segment ->
|
||||
when (segment) {
|
||||
is SearchReplace -> {
|
||||
runInEdt { clearAllHighlights() }
|
||||
|
||||
when (val validation =
|
||||
validateSearchReplacePattern(segment.search, segment.replace)) {
|
||||
is ValidationResult.Success -> {
|
||||
processCurrentFileSearchReplace(segment)
|
||||
}
|
||||
|
||||
is ValidationResult.Error -> {
|
||||
runInEdt {
|
||||
OverlayUtil.showNotification(
|
||||
"Warning: ${validation.message}",
|
||||
NotificationType.WARNING
|
||||
)
|
||||
}
|
||||
processCurrentFileSearchReplace(segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ReplaceWaiting -> {
|
||||
if (currentSearchPattern != null) {
|
||||
runInEdt {
|
||||
highlightSearchRegions(currentSearchPattern!!, true)
|
||||
updateHighlightState(
|
||||
HighlightState.FOUND,
|
||||
CodeGPTBundle.get("inlineEdit.status.preparingReplacement")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SearchWaiting -> {
|
||||
currentSearchPattern = segment.search
|
||||
|
||||
if (segment.search.isNotEmpty()) {
|
||||
runInEdt {
|
||||
highlightSearchRegions(segment.search, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(completionMessageBuilder: StringBuilder) {
|
||||
if (!isCurrentRequest()) return
|
||||
|
||||
runInEdt {
|
||||
isStreamingComplete = true
|
||||
|
||||
clearAllHighlights()
|
||||
highlightDebounceAlarm.cancelAllRequests()
|
||||
hintComponent?.let {
|
||||
editor.contentComponent.remove(it)
|
||||
hintComponent = null
|
||||
}
|
||||
|
||||
val userMessage = InlineEditConversationManager.addUserMessage(editor, userPrompt)
|
||||
val assistantSummary = buildAssistantSummaryForConversation()
|
||||
if (assistantSummary.isNotBlank()) {
|
||||
InlineEditConversationManager.addAssistantResponse(userMessage, assistantSummary)
|
||||
}
|
||||
|
||||
val hadChanges = showFinalDiff()
|
||||
|
||||
val popover = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
popover?.observableProperties?.hasPendingChanges?.set(hadChanges)
|
||||
popover?.setThinkingVisible(false)
|
||||
popover?.setInlineEditControlsVisible(hadChanges)
|
||||
|
||||
if (hadChanges) {
|
||||
val statusComponent = (editor.scrollPane as JBScrollPane).statusComponent
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK)?.let { existing ->
|
||||
statusComponent.remove(existing)
|
||||
}
|
||||
val compareLink = ActionLink("Open in Diff Viewer") {
|
||||
try {
|
||||
val originalDocText = runReadAction { editor.document.text }
|
||||
val usedRange = targetRange()
|
||||
val originalSelection = runReadAction { editor.document.getText(usedRange) }
|
||||
val modifiedSelection = applyAllSearchReplaceOperations(
|
||||
originalSelection,
|
||||
accumulatedSearchReplaceSegments
|
||||
)
|
||||
|
||||
val newContent =
|
||||
if (usedRange.startOffset == 0 && usedRange.endOffset == originalDocText.length) {
|
||||
modifiedSelection
|
||||
} else buildString(originalDocText.length + modifiedSelection.length) {
|
||||
append(originalDocText, 0, usedRange.startOffset)
|
||||
append(modifiedSelection)
|
||||
append(originalDocText, usedRange.endOffset, originalDocText.length)
|
||||
}
|
||||
|
||||
val originalVf = editor.virtualFile ?: return@ActionLink
|
||||
val tempFile = LightVirtualFile(originalVf.name, newContent)
|
||||
val diffRequest =
|
||||
EditorDiffUtil.createDiffRequest(project, tempFile, originalVf)
|
||||
DiffManager.getInstance().showDiff(project, diffRequest)
|
||||
} catch (e: Exception) {
|
||||
OverlayUtil.showNotification(
|
||||
"Failed to open diff: ${e.message}",
|
||||
NotificationType.ERROR
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
icon = AllIcons.Actions.Diff
|
||||
toolTipText = CodeGPTBundle.get("editor.diff.title")
|
||||
border = JBUI.Borders.empty(0, 6)
|
||||
}
|
||||
|
||||
statusComponent.add(compareLink)
|
||||
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK, compareLink)
|
||||
statusComponent.revalidate()
|
||||
statusComponent.repaint()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAssistantSummaryForConversation(): String {
|
||||
val vf = editor.virtualFile
|
||||
val language = vf?.extension ?: "txt"
|
||||
val path = vf?.path ?: "untitled"
|
||||
|
||||
if (accumulatedSearchReplaceSegments.isEmpty()) return ""
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.append("```$language:$path\n")
|
||||
accumulatedSearchReplaceSegments.forEach { seg ->
|
||||
val search = seg.search.trim()
|
||||
val replace = seg.replace.trim()
|
||||
if (search.isEmpty() && replace.isEmpty()) return@forEach
|
||||
sb.append("SEARCH\n")
|
||||
sb.append(search)
|
||||
sb.append("\nREPLACE\n")
|
||||
sb.append(replace)
|
||||
sb.append("\n---\n")
|
||||
}
|
||||
sb.append("```\n")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun onError(error: ErrorDetails, ex: Throwable) {
|
||||
if (!isCurrentRequest()) return
|
||||
|
||||
runInEdt {
|
||||
clearAllHighlights()
|
||||
highlightDebounceAlarm.cancelAllRequests()
|
||||
hintComponent?.let {
|
||||
editor.contentComponent.remove(it)
|
||||
hintComponent = null
|
||||
}
|
||||
val pop = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
pop?.setThinkingVisible(false)
|
||||
pop?.setInlineEditControlsVisible(false)
|
||||
}
|
||||
|
||||
OverlayUtil.showNotification(
|
||||
error.message,
|
||||
NotificationType.ERROR,
|
||||
NotificationAction.createSimpleExpiring("Upgrade plan") {
|
||||
BrowserUtil.open("https://tryproxy.io/#pricing")
|
||||
},
|
||||
)
|
||||
unlockEditorOnError()
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
private fun processCurrentFileSearchReplace(segment: SearchReplace) {
|
||||
accumulatedSearchReplaceSegments.add(segment)
|
||||
|
||||
try {
|
||||
val result = filterApplicableSegments(accumulatedSearchReplaceSegments)
|
||||
if (result.filteredCount > 0) {
|
||||
showFilteredWarning(result)
|
||||
}
|
||||
val range = targetRange()
|
||||
val originalContent = runReadAction { editor.document.getText(range) }
|
||||
val modifiedSoFar = applySimpleSearchReplace(originalContent, result.pairs)
|
||||
|
||||
val existingSession = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
|
||||
if (existingSession == null) {
|
||||
val session = InlineEditSession.start(
|
||||
project,
|
||||
editor,
|
||||
range,
|
||||
originalContent,
|
||||
modifiedSoFar
|
||||
)
|
||||
session.setInteractive(true)
|
||||
previewSessionStarted = true
|
||||
} else {
|
||||
existingSession.updateProposedText(modifiedSoFar, interactive = true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error while processing segment", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFinalDiff(): Boolean {
|
||||
if (!isStreamingComplete) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
val range = targetRange()
|
||||
val originalContent = runReadAction { editor.document.getText(range) }
|
||||
val modifiedContent =
|
||||
applyAllSearchReplaceOperations(originalContent, accumulatedSearchReplaceSegments)
|
||||
if (modifiedContent == originalContent) {
|
||||
val noChangesMsg = CodeGPTBundle.get("inlineEdit.status.noChanges")
|
||||
showInlineHint(noChangesMsg)
|
||||
OverlayUtil.showNotification(noChangesMsg, NotificationType.INFORMATION)
|
||||
return false
|
||||
}
|
||||
|
||||
val existingSession = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
if (existingSession == null) {
|
||||
InlineEditSession.start(project, editor, range, originalContent, modifiedContent)
|
||||
} else {
|
||||
existingSession.updateProposedText(modifiedContent, interactive = true)
|
||||
if (!existingSession.hasPendingHunks()) {
|
||||
val noChangesMsg = CodeGPTBundle.get("inlineEdit.status.noChanges")
|
||||
showInlineHint(noChangesMsg)
|
||||
OverlayUtil.showNotification(noChangesMsg, NotificationType.INFORMATION)
|
||||
return false
|
||||
}
|
||||
}
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.setInteractive(true)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Failed to build final diff", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun applyAllSearchReplaceOperations(
|
||||
originalContent: String,
|
||||
searchReplaceSegments: List<SearchReplace>
|
||||
): String {
|
||||
val filtered = filterApplicableSegments(searchReplaceSegments)
|
||||
if (filtered.filteredCount > 0) {
|
||||
showFilteredWarning(filtered)
|
||||
}
|
||||
return applySimpleSearchReplace(originalContent, filtered.pairs)
|
||||
}
|
||||
|
||||
private fun filterApplicableSegments(segments: List<SearchReplace>): FilterResult {
|
||||
val vf = editor.virtualFile
|
||||
val currentPath = vf?.path
|
||||
val fileName = vf?.name
|
||||
val fileExt = vf?.extension?.lowercase()
|
||||
return InlineEditFilter.filterSegments(currentPath, fileName, fileExt, segments)
|
||||
}
|
||||
|
||||
private fun showFilteredWarning(result: FilterResult) {
|
||||
if (result.filteredCount <= 0) return
|
||||
runInEdt {
|
||||
showInlineHint("Ignored ${result.filteredCount} invalid block(s)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLoading() {
|
||||
observableProperties.loading.set(false)
|
||||
project.let {
|
||||
CompletionProgressNotifier.Companion.update(it, false)
|
||||
}
|
||||
|
||||
val popover = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
popover?.onCompletionFinished()
|
||||
}
|
||||
|
||||
private fun unlockEditorOnError() {
|
||||
if (!editor.document.isWritable) {
|
||||
editor.document.setReadOnly(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
clearAllHighlights()
|
||||
highlightDebounceAlarm.cancelAllRequests()
|
||||
hintComponent?.let {
|
||||
editor.contentComponent.remove(it)
|
||||
}
|
||||
|
||||
editor.putUserData(LISTENER_KEY, null)
|
||||
}
|
||||
|
||||
private fun findPatternInContent(
|
||||
content: String,
|
||||
pattern: String,
|
||||
fuzzyMatch: Boolean = true
|
||||
): List<TextRange> {
|
||||
val matches = mutableListOf<TextRange>()
|
||||
val cleanPattern = pattern.trim()
|
||||
|
||||
if (cleanPattern.isEmpty()) {
|
||||
return matches
|
||||
}
|
||||
|
||||
var index = content.indexOf(cleanPattern)
|
||||
while (index >= 0) {
|
||||
matches.add(TextRange(index, index + cleanPattern.length))
|
||||
index = content.indexOf(cleanPattern, index + 1)
|
||||
}
|
||||
|
||||
if (matches.isEmpty() && fuzzyMatch && (pattern.contains("...") || cleanPattern.length < pattern.length)) {
|
||||
val partialMatches = findPartialMatches(content, cleanPattern)
|
||||
matches.addAll(partialMatches)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private fun findPartialMatches(content: String, pattern: String): List<TextRange> {
|
||||
val identifiers = extractIdentifiers(pattern)
|
||||
if (identifiers.isEmpty()) {
|
||||
val firstLine = pattern.lines().firstOrNull()?.trim()
|
||||
if (!firstLine.isNullOrEmpty() && content.contains(firstLine)) {
|
||||
val index = content.indexOf(firstLine)
|
||||
return listOf(expandToLogicalBlock(content, index))
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val matches = mutableListOf<TextRange>()
|
||||
val lines = content.lines()
|
||||
|
||||
for (i in lines.indices) {
|
||||
if (identifiers.all { identifier -> lines[i].contains(identifier) }) {
|
||||
val blockRange = expandToLogicalBlock(content, getLineStartOffset(content, i))
|
||||
matches.add(blockRange)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private fun extractIdentifiers(pattern: String): List<String> {
|
||||
val identifierRegex =
|
||||
"\\b(class|function|def|var|val|let|const|public|private)\\s+(\\w+)".toRegex()
|
||||
val methodRegex = "\\b(\\w+)\\s*\\(".toRegex()
|
||||
val variableRegex = "\\b[a-zA-Z_][a-zA-Z0-9_]{2,}\\b".toRegex()
|
||||
|
||||
val identifiers = mutableSetOf<String>()
|
||||
|
||||
identifierRegex.findAll(pattern).forEach { match ->
|
||||
identifiers.add(match.groupValues[2])
|
||||
}
|
||||
|
||||
methodRegex.findAll(pattern).forEach { match ->
|
||||
identifiers.add(match.groupValues[1])
|
||||
}
|
||||
|
||||
variableRegex.findAll(pattern).forEach { match ->
|
||||
val identifier = match.value
|
||||
if (identifier.length > 2 && !identifier.matches("\\d+".toRegex())) {
|
||||
identifiers.add(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
return identifiers.toList().take(3)
|
||||
}
|
||||
|
||||
private fun expandToLogicalBlock(content: String, charOffset: Int): TextRange {
|
||||
val lines = content.lines()
|
||||
val lineIndex = getLineIndex(content, charOffset)
|
||||
|
||||
if (lineIndex >= lines.size) {
|
||||
return TextRange(charOffset, charOffset)
|
||||
}
|
||||
|
||||
var startLine = lineIndex
|
||||
var endLine = lineIndex
|
||||
var braceCount = 0
|
||||
|
||||
for (i in lineIndex downTo 0) {
|
||||
val line = lines[i]
|
||||
if (line.contains("{")) braceCount++
|
||||
if (line.contains("}")) braceCount--
|
||||
|
||||
if (braceCount > 0 || isBlockStart(line)) {
|
||||
startLine = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
braceCount = 0
|
||||
for (i in lineIndex until lines.size) {
|
||||
val line = lines[i]
|
||||
if (line.contains("{")) braceCount++
|
||||
if (line.contains("}")) braceCount--
|
||||
|
||||
if (braceCount == 0 && line.contains("}") && i > lineIndex) {
|
||||
endLine = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return convertLinesToTextRange(content, startLine, endLine)
|
||||
}
|
||||
|
||||
private fun isBlockStart(line: String): Boolean {
|
||||
val trimmed = line.trim()
|
||||
return trimmed.startsWith("class ") ||
|
||||
trimmed.startsWith("function ") ||
|
||||
trimmed.startsWith("def ") ||
|
||||
trimmed.startsWith("public ") ||
|
||||
trimmed.startsWith("private ") ||
|
||||
trimmed.contains("{") ||
|
||||
trimmed.endsWith(":")
|
||||
}
|
||||
|
||||
private fun getLineIndex(content: String, charOffset: Int): Int {
|
||||
return content.substring(0, charOffset).count { it == '\n' }
|
||||
}
|
||||
|
||||
private fun getLineStartOffset(content: String, lineIndex: Int): Int {
|
||||
val lines = content.lines()
|
||||
var offset = 0
|
||||
|
||||
for (i in 0 until lineIndex) {
|
||||
if (i < lines.size) {
|
||||
offset += lines[i].length + 1
|
||||
}
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
private fun convertLinesToTextRange(content: String, startLine: Int, endLine: Int): TextRange {
|
||||
val lines = content.lines()
|
||||
|
||||
var startOffset = 0
|
||||
for (i in 0 until startLine) {
|
||||
if (i < lines.size) {
|
||||
startOffset += lines[i].length + 1
|
||||
}
|
||||
}
|
||||
|
||||
var endOffset = startOffset
|
||||
for (i in startLine..endLine) {
|
||||
if (i < lines.size) {
|
||||
endOffset += lines[i].length
|
||||
if (i < lines.size - 1) endOffset += 1
|
||||
}
|
||||
}
|
||||
|
||||
return TextRange(startOffset, endOffset)
|
||||
}
|
||||
|
||||
private fun highlightSearchRegions(pattern: String, isReplaceReady: Boolean = false) {
|
||||
runInEdt {
|
||||
clearAllHighlights()
|
||||
|
||||
if (pattern.isEmpty()) {
|
||||
return@runInEdt
|
||||
}
|
||||
|
||||
val hasSelection = selectionTextRange.startOffset < selectionTextRange.endOffset
|
||||
val (content, baseOffset) = if (hasSelection) {
|
||||
editor.document.getText(selectionTextRange) to selectionTextRange.startOffset
|
||||
} else {
|
||||
editor.document.text to 0
|
||||
}
|
||||
|
||||
val matches = findPatternInContent(content, pattern)
|
||||
if (matches.isEmpty()) {
|
||||
showPatternNotFoundHint(pattern)
|
||||
return@runInEdt
|
||||
}
|
||||
|
||||
matches.forEach { range ->
|
||||
|
||||
val absoluteRange =
|
||||
TextRange(baseOffset + range.startOffset, baseOffset + range.endOffset)
|
||||
val color = if (isReplaceReady) REPLACE_READY_COLOR else SEARCH_HIGHLIGHT_COLOR
|
||||
val tooltip = if (isReplaceReady) CodeGPTBundle.get("inlineEdit.tooltip.ready")
|
||||
else CodeGPTBundle.get("inlineEdit.tooltip.searching")
|
||||
|
||||
searchHighlighters.add(createHighlighter(absoluteRange, color, tooltip))
|
||||
}
|
||||
|
||||
if (matches.isNotEmpty()) {
|
||||
ensureVisible(baseOffset + matches.first().startOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHighlightState(state: HighlightState, message: String? = null) {
|
||||
val color = when (state) {
|
||||
HighlightState.SEARCHING -> SEARCH_HIGHLIGHT_COLOR
|
||||
HighlightState.FOUND -> REPLACE_READY_COLOR
|
||||
HighlightState.REPLACING -> JBColor(
|
||||
Color(59, 149, 255, 80),
|
||||
Color(59, 149, 255, 60)
|
||||
)
|
||||
|
||||
HighlightState.ERROR -> JBColor(Color(255, 59, 59, 80), Color(255, 59, 59, 60))
|
||||
}
|
||||
|
||||
searchHighlighters.forEach { highlighter ->
|
||||
highlighter.getTextAttributes(editor.colorsScheme)?.backgroundColor = color
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
showInlineHint(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPatternNotFoundHint(pattern: String) {
|
||||
val shortPattern = if (pattern.length > 30) "${pattern.take(30)}..." else pattern
|
||||
showInlineHint(
|
||||
CodeGPTBundle.get("inlineEdit.hint.searchingFor", shortPattern)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showInlineHint(message: String) {
|
||||
runInEdt {
|
||||
hintComponent?.let {
|
||||
editor.contentComponent.remove(it)
|
||||
}
|
||||
|
||||
val hint = JLabel(message).apply {
|
||||
foreground = JBColor.GRAY
|
||||
font = font.deriveFont(Font.ITALIC, 12f)
|
||||
border = JBUI.Borders.empty(2, 8)
|
||||
background = editor.backgroundColor
|
||||
isOpaque = true
|
||||
}
|
||||
|
||||
val targetOffset = if (searchHighlighters.isNotEmpty()) {
|
||||
searchHighlighters.first().startOffset
|
||||
} else {
|
||||
selectionTextRange.startOffset
|
||||
}
|
||||
|
||||
val comp = editor.contentComponent
|
||||
val point = editor.visualPositionToXY(editor.offsetToVisualPosition(targetOffset))
|
||||
val visible = comp.visibleRect
|
||||
|
||||
val prefW = 300
|
||||
val prefH = 20
|
||||
var x = point.x
|
||||
var y = point.y - 25
|
||||
|
||||
if (x < visible.x) x = visible.x + JBUI.scale(8)
|
||||
if (y < visible.y) y = visible.y + JBUI.scale(8)
|
||||
if (x + prefW > visible.x + visible.width) x =
|
||||
visible.x + visible.width - prefW - JBUI.scale(8)
|
||||
if (y + prefH > visible.y + visible.height) y =
|
||||
visible.y + visible.height - prefH - JBUI.scale(8)
|
||||
|
||||
hint.setBounds(x, y, prefW, prefH)
|
||||
|
||||
comp.add(hint)
|
||||
hintComponent = hint
|
||||
|
||||
Timer().schedule(3000) {
|
||||
runInEdt {
|
||||
if (hintComponent == hint) {
|
||||
comp.remove(hint)
|
||||
comp.repaint()
|
||||
hintComponent = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showHint(message: String) {
|
||||
showInlineHint(message)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val LISTENER_KEY =
|
||||
Key.create<InlineEditSearchReplaceListener>("InlineEditSearchReplaceListener")
|
||||
val REQUEST_ID_KEY = Key.create<Long>("InlineEditRequestId")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import com.intellij.diff.comparison.ComparisonManager
|
||||
import com.intellij.diff.comparison.ComparisonPolicy
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.CustomShortcutSet
|
||||
import com.intellij.openapi.actionSystem.EmptyAction
|
||||
import com.intellij.openapi.actionSystem.KeyboardShortcut
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.editor.RangeMarker
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.keymap.KeymapManager
|
||||
import com.intellij.openapi.progress.EmptyProgressIndicator
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import ee.carlrobert.codegpt.inlineedit.InlineEditInlayRenderer
|
||||
import ee.carlrobert.codegpt.inlineedit.InlineEditKeyEventDispatcher
|
||||
import ee.carlrobert.codegpt.ui.InlineEditPopover
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.KeyStroke
|
||||
import kotlin.math.abs
|
||||
|
||||
class InlineEditSession(
|
||||
private val project: Project,
|
||||
private val editor: EditorEx,
|
||||
private val baseRange: TextRange,
|
||||
private val initialBaseText: String,
|
||||
private var proposedText: String
|
||||
) : Disposable {
|
||||
|
||||
data class Hunk(
|
||||
val baseMarker: RangeMarker,
|
||||
val proposedSlice: String,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int,
|
||||
var accepted: Boolean = false,
|
||||
var rejected: Boolean = false
|
||||
)
|
||||
|
||||
private val renderer = InlineEditInlayRenderer(editor, project)
|
||||
private val hunks = mutableListOf<Hunk>()
|
||||
private val lockedRanges = mutableListOf<RangeMarker>()
|
||||
private val rejectedRanges = mutableListOf<RangeMarker>()
|
||||
private val rootMarker: RangeMarker = runReadAction {
|
||||
editor.document.createRangeMarker(baseRange.startOffset, baseRange.endOffset, true).apply {
|
||||
isGreedyToLeft = true
|
||||
isGreedyToRight = true
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
buildAndRenderHunks()
|
||||
|
||||
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, this)
|
||||
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, renderer)
|
||||
|
||||
registerEditorScopedShortcuts()
|
||||
InlineEditKeyEventDispatcher(
|
||||
project,
|
||||
editor,
|
||||
onAccept = { acceptNearestToCaret() },
|
||||
onReject = { rejectNearestToCaret() }
|
||||
).register(this)
|
||||
}
|
||||
|
||||
private fun buildAndRenderHunks() {
|
||||
val newHunks = computeHunks()
|
||||
hunks.clear()
|
||||
hunks.addAll(newHunks)
|
||||
renderer.renderHunks(hunks)
|
||||
}
|
||||
|
||||
private fun computeHunks(): List<Hunk> {
|
||||
val (baseNow, baseStartOffset) = runReadAction {
|
||||
val start = rootMarker.startOffset.coerceAtLeast(0)
|
||||
val end =
|
||||
rootMarker.endOffset.coerceAtLeast(start).coerceAtMost(editor.document.textLength)
|
||||
Pair(editor.document.getText(TextRange(start, end)), start)
|
||||
}
|
||||
|
||||
val lineFragments = ComparisonManager.getInstance()
|
||||
.compareLines(baseNow, proposedText, ComparisonPolicy.DEFAULT, EmptyProgressIndicator())
|
||||
if (lineFragments.isEmpty()) return emptyList()
|
||||
|
||||
val baseLineOffsets = computeLineStartOffsets(baseNow)
|
||||
val proposedLineOffsets = computeLineStartOffsets(proposedText)
|
||||
|
||||
val rawHunks = runReadAction {
|
||||
val list = mutableListOf<Hunk>()
|
||||
val docLength = editor.document.textLength
|
||||
for (frag in lineFragments) {
|
||||
val baseStart =
|
||||
if (frag.startLine1 < baseLineOffsets.size) baseLineOffsets[frag.startLine1] else baseNow.length
|
||||
val baseEnd =
|
||||
if (frag.endLine1 < baseLineOffsets.size) baseLineOffsets[frag.endLine1] else baseNow.length
|
||||
val proposedStart =
|
||||
if (frag.startLine2 < proposedLineOffsets.size) proposedLineOffsets[frag.startLine2] else proposedText.length
|
||||
val proposedEnd =
|
||||
if (frag.endLine2 < proposedLineOffsets.size) proposedLineOffsets[frag.endLine2] else proposedText.length
|
||||
|
||||
val oldSlice = safeSlice(baseNow, baseStart, baseEnd)
|
||||
val newSlice = safeSlice(proposedText, proposedStart, proposedEnd)
|
||||
if (oldSlice == newSlice) continue
|
||||
|
||||
val rawStart = baseStartOffset + baseStart
|
||||
val rawEnd = baseStartOffset + baseEnd
|
||||
val start = rawStart.coerceIn(0, docLength)
|
||||
val end = rawEnd.coerceIn(start, docLength)
|
||||
|
||||
val marker = editor.document.createRangeMarker(start, end, true).apply {
|
||||
isGreedyToLeft = true
|
||||
isGreedyToRight = true
|
||||
}
|
||||
list.add(Hunk(marker, newSlice, start, end))
|
||||
}
|
||||
list
|
||||
}
|
||||
return rawHunks.filter { h ->
|
||||
lockedRanges.none { lock ->
|
||||
rangesOverlap(
|
||||
h.startOffset,
|
||||
h.endOffset,
|
||||
lock.startOffset,
|
||||
lock.endOffset
|
||||
)
|
||||
} &&
|
||||
rejectedRanges.none { rej ->
|
||||
rangesOverlap(
|
||||
h.startOffset,
|
||||
h.endOffset,
|
||||
rej.startOffset,
|
||||
rej.endOffset
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProposedText(newText: String, interactive: Boolean) {
|
||||
this.proposedText = newText
|
||||
val newHunks = computeHunks()
|
||||
hunks.clear()
|
||||
hunks.addAll(newHunks)
|
||||
renderer.setInteractive(interactive)
|
||||
renderer.replaceHunks(hunks)
|
||||
}
|
||||
|
||||
fun acceptNearestToCaret() {
|
||||
val caret = editor.caretModel.offset
|
||||
val next = hunks
|
||||
.filter { !it.accepted && !it.rejected }
|
||||
.minByOrNull { abs(it.startOffset - caret) }
|
||||
if (next != null) acceptHunk(next)
|
||||
}
|
||||
|
||||
fun rejectNearestToCaret() {
|
||||
val caret = editor.caretModel.offset
|
||||
val next = hunks
|
||||
.filter { !it.accepted && !it.rejected }
|
||||
.minByOrNull { abs(it.startOffset - caret) }
|
||||
if (next != null) rejectHunk(next)
|
||||
}
|
||||
|
||||
fun acceptAll() {
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.markChangesAsAccepted()
|
||||
|
||||
hunks
|
||||
.filter { !it.accepted && !it.rejected }
|
||||
.sortedByDescending { it.baseMarker.startOffset }
|
||||
.forEach { acceptHunk(it) }
|
||||
removeCompareLinkIfAny()
|
||||
dispose()
|
||||
}
|
||||
|
||||
fun rejectAll() {
|
||||
hunks
|
||||
.filter { !it.accepted && !it.rejected }
|
||||
.forEach { rejectHunk(it) }
|
||||
removeCompareLinkIfAny()
|
||||
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.triggerPromptRestoration()
|
||||
|
||||
dispose()
|
||||
}
|
||||
|
||||
private fun acceptHunk(hunk: Hunk) {
|
||||
if (hunk.accepted || hunk.rejected) return
|
||||
val start = hunk.baseMarker.startOffset
|
||||
val end = hunk.baseMarker.endOffset
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.markChangesAsAccepted()
|
||||
editor.document.replaceString(start, end, hunk.proposedSlice)
|
||||
hunk.accepted = true
|
||||
val newEnd = start + hunk.proposedSlice.length
|
||||
val lock = editor.document.createRangeMarker(start, newEnd, true).apply {
|
||||
isGreedyToLeft = true
|
||||
isGreedyToRight = true
|
||||
}
|
||||
lockedRanges.add(lock)
|
||||
val newHunks = computeHunks()
|
||||
hunks.clear()
|
||||
hunks.addAll(newHunks)
|
||||
renderer.replaceHunks(hunks)
|
||||
if (hunks.isEmpty()) {
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.setInlineEditControlsVisible(false)
|
||||
removeCompareLinkIfAny()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rejectHunk(hunk: Hunk) {
|
||||
if (hunk.accepted || hunk.rejected) return
|
||||
|
||||
hunk.rejected = true
|
||||
|
||||
val start = hunk.baseMarker.startOffset
|
||||
val end = hunk.baseMarker.endOffset
|
||||
val safeStart = start.coerceIn(0, editor.document.textLength)
|
||||
val safeEnd = end.coerceIn(safeStart, editor.document.textLength)
|
||||
val marker = editor.document.createRangeMarker(safeStart, safeEnd, true).apply {
|
||||
isGreedyToLeft = true
|
||||
isGreedyToRight = true
|
||||
}
|
||||
rejectedRanges.add(marker)
|
||||
|
||||
val newHunks = computeHunks()
|
||||
hunks.clear()
|
||||
hunks.addAll(newHunks)
|
||||
|
||||
renderer.replaceHunks(hunks)
|
||||
if (hunks.isEmpty()) {
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.setInlineEditControlsVisible(false)
|
||||
removeCompareLinkIfAny()
|
||||
}
|
||||
}
|
||||
|
||||
fun accept(hunk: Hunk) = acceptHunk(hunk)
|
||||
fun reject(hunk: Hunk) = rejectHunk(hunk)
|
||||
fun setInteractive(enabled: Boolean) = renderer.setInteractive(enabled)
|
||||
|
||||
fun hasPendingHunks(): Boolean {
|
||||
return hunks.any { !it.accepted && !it.rejected }
|
||||
}
|
||||
|
||||
private fun rangesOverlap(aStart: Int, aEnd: Int, bStart: Int, bEnd: Int): Boolean {
|
||||
val start = maxOf(aStart, bStart)
|
||||
val end = minOf(aEnd, bEnd)
|
||||
return start < end
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, null)
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.dispose()
|
||||
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, null)
|
||||
editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.setInlineEditControlsVisible(false)
|
||||
|
||||
removeCompareLinkIfAny()
|
||||
}
|
||||
|
||||
private fun removeCompareLinkIfAny() {
|
||||
val comp = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK) ?: return
|
||||
val statusComponent = (editor.scrollPane as JBScrollPane).statusComponent
|
||||
statusComponent.remove(comp)
|
||||
statusComponent.revalidate()
|
||||
statusComponent.repaint()
|
||||
editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK, null)
|
||||
}
|
||||
|
||||
private fun computeLineStartOffsets(text: String): IntArray {
|
||||
val lines = text.split('\n')
|
||||
val offsets = IntArray(lines.size + 1)
|
||||
var sum = 0
|
||||
for (i in lines.indices) {
|
||||
offsets[i] = sum
|
||||
sum += lines[i].length + 1
|
||||
}
|
||||
offsets[lines.size] = sum
|
||||
return offsets
|
||||
}
|
||||
|
||||
private fun safeSlice(text: String, start: Int, end: Int): String {
|
||||
val s = start.coerceIn(0, text.length)
|
||||
val e = end.coerceIn(s, text.length)
|
||||
return text.substring(s, e)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun start(
|
||||
project: Project,
|
||||
editor: EditorEx,
|
||||
baseRange: TextRange,
|
||||
baseText: String,
|
||||
proposedText: String
|
||||
): InlineEditSession {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.dispose()
|
||||
return InlineEditSession(project, editor, baseRange, baseText, proposedText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerEditorScopedShortcuts() {
|
||||
val am = ActionManager.getInstance()
|
||||
am.getAction("CodeGPT.AcceptCurrentInlineEdit")?.let { action ->
|
||||
val ks = resolvePreferredKeyStroke("CodeGPT.AcceptCurrentInlineEdit", KeyEvent.VK_Y)
|
||||
val wrapped = EmptyAction.wrap(action)
|
||||
wrapped.registerCustomShortcutSet(
|
||||
CustomShortcutSet(KeyboardShortcut(ks, null)),
|
||||
editor.contentComponent,
|
||||
this
|
||||
)
|
||||
}
|
||||
am.getAction("CodeGPT.RejectCurrentInlineEdit")?.let { action ->
|
||||
val ks = resolvePreferredKeyStroke("CodeGPT.RejectCurrentInlineEdit", KeyEvent.VK_N)
|
||||
val wrapped = EmptyAction.wrap(action)
|
||||
wrapped.registerCustomShortcutSet(
|
||||
CustomShortcutSet(KeyboardShortcut(ks, null)),
|
||||
editor.contentComponent,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
am.getAction("codegpt.acceptInlineEdit")?.let { editorAction ->
|
||||
val metaEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.META_DOWN_MASK)
|
||||
val ctrlEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK)
|
||||
val shortcuts = arrayOf(
|
||||
KeyboardShortcut(metaEnter, null),
|
||||
KeyboardShortcut(ctrlEnter, null)
|
||||
)
|
||||
val wrapped = EmptyAction.wrap(editorAction)
|
||||
wrapped.registerCustomShortcutSet(
|
||||
CustomShortcutSet(*shortcuts),
|
||||
editor.contentComponent,
|
||||
this
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePreferredKeyStroke(actionId: String, keyCode: Int): KeyStroke {
|
||||
val keymap = KeymapManager.getInstance().activeKeymap
|
||||
val shortcuts = keymap.getShortcuts(actionId)
|
||||
val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK)
|
||||
val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK)
|
||||
val fromKeymap =
|
||||
shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs }
|
||||
?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs }
|
||||
val ks = (fromKeymap as? KeyboardShortcut)?.firstKeyStroke
|
||||
if (ks != null) return ks
|
||||
return if (SystemInfo.isMac) macKs else winKs
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package ee.carlrobert.codegpt.inlineedit
|
||||
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import ee.carlrobert.codegpt.ReferencedFile
|
||||
import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier
|
||||
import ee.carlrobert.codegpt.completions.CompletionRequestService
|
||||
import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.ui.InlineEditPopover
|
||||
import ee.carlrobert.codegpt.ui.ObservableProperties
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import okhttp3.sse.EventSource
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class InlineEditSubmissionHandler(
|
||||
private val editor: Editor,
|
||||
private val observableProperties: ObservableProperties,
|
||||
) {
|
||||
|
||||
private val previousSourceRef = AtomicReference<String?>(null)
|
||||
private val previousPromptRef = AtomicReference<String?>(null)
|
||||
private val currentEventSourceRef = AtomicReference<EventSource?>(null)
|
||||
private val logger = Logger.getInstance(InlineEditSubmissionHandler::class.java)
|
||||
|
||||
fun handleSubmit(
|
||||
userPrompt: String,
|
||||
referencedFiles: List<ReferencedFile>? = null,
|
||||
gitDiff: String? = null,
|
||||
conversationHistory: List<Conversation>? = null,
|
||||
diagnosticsInfo: String? = null
|
||||
) {
|
||||
editor.project?.let {
|
||||
CompletionProgressNotifier.Companion.update(it, true)
|
||||
}
|
||||
|
||||
observableProperties.loading.set(true)
|
||||
observableProperties.submitted.set(true)
|
||||
|
||||
previousPromptRef.getAndSet(userPrompt)
|
||||
previousSourceRef.getAndSet(editor.document.text)
|
||||
|
||||
runInEdt { editor.selectionModel.removeSelection() }
|
||||
|
||||
val file = FileDocumentManager.getInstance().getFile(editor.document)
|
||||
val editorEx = editor as? EditorEx ?: return
|
||||
val parameters = InlineEditCompletionParameters(
|
||||
userPrompt,
|
||||
runReadAction { editor.selectionModel.selectedText },
|
||||
file?.path,
|
||||
file?.extension,
|
||||
editor.project?.basePath,
|
||||
referencedFiles,
|
||||
gitDiff,
|
||||
InlineEditConversationManager.getOrCreate(editorEx),
|
||||
conversationHistory,
|
||||
diagnosticsInfo
|
||||
)
|
||||
|
||||
val requestId = System.nanoTime()
|
||||
editorEx.putUserData(InlineEditSearchReplaceListener.REQUEST_ID_KEY, requestId)
|
||||
|
||||
val listener = InlineEditSearchReplaceListener(
|
||||
editorEx,
|
||||
observableProperties,
|
||||
TextRange(
|
||||
runReadAction { editor.selectionModel.selectionStart },
|
||||
runReadAction { editor.selectionModel.selectionEnd },
|
||||
),
|
||||
requestId,
|
||||
userPrompt
|
||||
)
|
||||
|
||||
editorEx.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, listener)
|
||||
|
||||
listener.showHint("Submitting inline edit…")
|
||||
|
||||
editorEx.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.apply {
|
||||
setInlineEditControlsVisible(false)
|
||||
setThinkingVisible(true)
|
||||
}
|
||||
|
||||
try {
|
||||
currentEventSourceRef.getAndSet(null)?.cancel()
|
||||
|
||||
val eventSource = service<CompletionRequestService>().getInlineEditCompletionAsync(
|
||||
parameters,
|
||||
listener
|
||||
)
|
||||
currentEventSourceRef.set(eventSource)
|
||||
|
||||
} catch (ex: Exception) {
|
||||
logger.warn("InlineEdit: request dispatch failed", ex)
|
||||
runInEdt {
|
||||
OverlayUtil.showNotification(
|
||||
ex.message ?: "Inline Edit request failed",
|
||||
NotificationType.ERROR
|
||||
)
|
||||
observableProperties.loading.set(false)
|
||||
observableProperties.submitted.set(false)
|
||||
editorEx.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.setThinkingVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleReject(clearConversation: Boolean = false) {
|
||||
cancelActiveRequest()
|
||||
(editor as? EditorEx)?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.setThinkingVisible(false)
|
||||
val prevSource = previousSourceRef.get()
|
||||
if (!observableProperties.accepted.get() && prevSource != null) {
|
||||
revertAllChanges(prevSource)
|
||||
}
|
||||
|
||||
restorePreviousPrompt()
|
||||
runInEdt {
|
||||
val editorEx = editor as? EditorEx
|
||||
editorEx?.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.dispose()
|
||||
editorEx?.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, null)
|
||||
editorEx?.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.dispose()
|
||||
editorEx?.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, null)
|
||||
if (clearConversation) {
|
||||
editorEx?.let { InlineEditConversationManager.clear(it) }
|
||||
}
|
||||
editorEx?.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, null)
|
||||
|
||||
observableProperties.loading.set(false)
|
||||
observableProperties.submitted.set(false)
|
||||
editor.project?.let { project ->
|
||||
CompletionProgressNotifier.Companion.update(project, false)
|
||||
}
|
||||
editorEx?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.onCompletionFinished()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelActiveRequest() {
|
||||
val editorEx = editor as? EditorEx
|
||||
val newRequestId = System.nanoTime()
|
||||
editorEx?.putUserData(InlineEditSearchReplaceListener.REQUEST_ID_KEY, newRequestId)
|
||||
|
||||
currentEventSourceRef.getAndSet(null)?.cancel()
|
||||
}
|
||||
|
||||
private fun revertAllChanges(prevSource: String) {
|
||||
editor.project?.let { project ->
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
editor.document.replaceString(
|
||||
0,
|
||||
editor.document.textLength,
|
||||
StringUtil.convertLineSeparators(prevSource)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restorePreviousPrompt() {
|
||||
val prevPrompt = previousPromptRef.get()
|
||||
if (prevPrompt != null) {
|
||||
(editor as? EditorEx)
|
||||
?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)
|
||||
?.restorePromptAndFocus(prevPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import com.intellij.openapi.components.service
|
|||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import ee.carlrobert.codegpt.settings.GeneralSettings
|
||||
import ee.carlrobert.codegpt.settings.models.ModelRegistry
|
||||
import ee.carlrobert.codegpt.settings.models.ModelSettings
|
||||
import ee.carlrobert.codegpt.settings.models.ModelSettingsState
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
|
|
@ -45,7 +44,7 @@ object LegacySettingsMigration {
|
|||
setModelSelection(FeatureType.CHAT, chatModel, selectedService)
|
||||
setModelSelection(FeatureType.AUTO_APPLY, chatModel, selectedService)
|
||||
setModelSelection(FeatureType.COMMIT_MESSAGE, chatModel, selectedService)
|
||||
setModelSelection(FeatureType.EDIT_CODE, chatModel, selectedService)
|
||||
setModelSelection(FeatureType.INLINE_EDIT, chatModel, selectedService)
|
||||
setModelSelection(FeatureType.LOOKUP, chatModel, selectedService)
|
||||
|
||||
val codeModel = getLegacyCodeModelForService(selectedService)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class ModelRegistry {
|
|||
FeatureType.CODE_COMPLETION,
|
||||
FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.EDIT_CODE,
|
||||
FeatureType.INLINE_EDIT,
|
||||
FeatureType.NEXT_EDIT,
|
||||
FeatureType.LOOKUP
|
||||
)
|
||||
|
|
@ -57,49 +57,49 @@ class ModelRegistry {
|
|||
ServiceType.OPENAI,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.ANTHROPIC to ModelCapability(
|
||||
ServiceType.ANTHROPIC,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.EDIT_CODE, FeatureType.LOOKUP
|
||||
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.GOOGLE to ModelCapability(
|
||||
ServiceType.GOOGLE,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.EDIT_CODE, FeatureType.LOOKUP
|
||||
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.MISTRAL to ModelCapability(
|
||||
ServiceType.MISTRAL,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.OLLAMA to ModelCapability(
|
||||
ServiceType.OLLAMA,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.LLAMA_CPP to ModelCapability(
|
||||
ServiceType.LLAMA_CPP,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.CUSTOM_OPENAI to ModelCapability(
|
||||
ServiceType.CUSTOM_OPENAI,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -121,7 +121,7 @@ class ModelRegistry {
|
|||
GPT_5_MINI,
|
||||
"GPT-5 Mini"
|
||||
),
|
||||
FeatureType.EDIT_CODE to ModelSelection(
|
||||
FeatureType.INLINE_EDIT to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
GPT_5_MINI,
|
||||
"GPT-5 Mini"
|
||||
|
|
@ -150,7 +150,7 @@ class ModelRegistry {
|
|||
QWEN3_CODER,
|
||||
"Qwen3 Coder"
|
||||
),
|
||||
FeatureType.EDIT_CODE to ModelSelection(
|
||||
FeatureType.INLINE_EDIT to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
QWEN3_CODER,
|
||||
"Qwen3 Coder"
|
||||
|
|
@ -171,7 +171,7 @@ class ModelRegistry {
|
|||
),
|
||||
FeatureType.AUTO_APPLY to ModelSelection(ServiceType.PROXYAI, GPT_5, "GPT-5"),
|
||||
FeatureType.COMMIT_MESSAGE to ModelSelection(ServiceType.PROXYAI, GPT_5, "GPT-5"),
|
||||
FeatureType.EDIT_CODE to ModelSelection(
|
||||
FeatureType.INLINE_EDIT to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
CLAUDE_4_SONNET,
|
||||
"Claude 4 Sonnet"
|
||||
|
|
@ -202,7 +202,7 @@ class ModelRegistry {
|
|||
GPT_5_MINI,
|
||||
"GPT-5 Mini"
|
||||
),
|
||||
FeatureType.EDIT_CODE to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"),
|
||||
FeatureType.INLINE_EDIT to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"),
|
||||
FeatureType.LOOKUP to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"),
|
||||
FeatureType.CODE_COMPLETION to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
|
|
@ -215,7 +215,7 @@ class ModelRegistry {
|
|||
fun getAllModelsForFeature(featureType: FeatureType): List<ModelSelection> {
|
||||
return when (featureType) {
|
||||
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.EDIT_CODE, FeatureType.LOOKUP -> getAllChatModels()
|
||||
FeatureType.INLINE_EDIT, FeatureType.LOOKUP -> getAllChatModels()
|
||||
|
||||
FeatureType.CODE_COMPLETION -> getAllCodeModels()
|
||||
FeatureType.NEXT_EDIT -> getNextEditModels()
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package ee.carlrobert.codegpt.settings.models
|
|||
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.*
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import ee.carlrobert.codegpt.settings.migration.LegacySettingsMigration
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
|
|
@ -32,7 +30,7 @@ class ModelSettings : SimplePersistentStateComponent<ModelSettingsState>(ModelSe
|
|||
FeatureType.COMMIT_MESSAGE to PublisherMethod { publisher, model, serviceType ->
|
||||
publisher.commitMessageModelChanged(model, serviceType)
|
||||
},
|
||||
FeatureType.EDIT_CODE to PublisherMethod { publisher, model, serviceType ->
|
||||
FeatureType.INLINE_EDIT to PublisherMethod { publisher, model, serviceType ->
|
||||
publisher.editCodeModelChanged(model, serviceType)
|
||||
},
|
||||
FeatureType.NEXT_EDIT to PublisherMethod { publisher, model, serviceType ->
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class ModelSettingsForm(
|
|||
FeatureType.COMMIT_MESSAGE,
|
||||
"settings.models.commitMessages.label"
|
||||
),
|
||||
FeatureConfig(FeatureType.EDIT_CODE, "settings.models.editCode.label"),
|
||||
FeatureConfig(FeatureType.INLINE_EDIT, "settings.models.editCode.label"),
|
||||
FeatureConfig(FeatureType.LOOKUP, "settings.models.nameLookups.label")
|
||||
)
|
||||
),
|
||||
|
|
@ -90,7 +90,7 @@ class ModelSettingsForm(
|
|||
}
|
||||
|
||||
override fun editCodeModelChanged(newModel: String, serviceType: ServiceType) {
|
||||
modelChanged(FeatureType.EDIT_CODE, newModel, serviceType)
|
||||
modelChanged(FeatureType.INLINE_EDIT, newModel, serviceType)
|
||||
}
|
||||
|
||||
override fun nextEditModelChanged(newModel: String, serviceType: ServiceType) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ class CompleteMessageParser : MessageParser {
|
|||
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)
|
||||
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)
|
||||
|
||||
|
|
@ -20,6 +23,12 @@ class CompleteMessageParser : MessageParser {
|
|||
private const val CODE_CONTENT_GROUP_INDEX = 3
|
||||
private const val SEARCH_CONTENT_GROUP_INDEX = 1
|
||||
private const val REPLACE_CONTENT_GROUP_INDEX = 2
|
||||
|
||||
private val TOLERANT_SEARCH_START =
|
||||
Regex("""^\s*<{3,}(\s*SEARCH.*)?$""", RegexOption.IGNORE_CASE)
|
||||
private val TOLERANT_SEPARATOR = Regex("""^\s*={3,}\s*$""")
|
||||
private val TOLERANT_REPLACE_END =
|
||||
Regex("""^\s*>{3,}(\s*REPLACE.*)?$""", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
var extractedThought: String? = null
|
||||
|
|
@ -137,12 +146,25 @@ class CompleteMessageParser : MessageParser {
|
|||
|
||||
while (searchReplaceMatcher.find()) {
|
||||
foundSearchReplace = true
|
||||
addCodeSegmentIfExists(codeContent, lastProcessedIndex, searchReplaceMatcher.start(), language, filePath)
|
||||
addCodeSegmentIfExists(
|
||||
codeContent,
|
||||
lastProcessedIndex,
|
||||
searchReplaceMatcher.start(),
|
||||
language,
|
||||
filePath
|
||||
)
|
||||
addSearchReplaceSegment(searchReplaceMatcher, language, filePath)
|
||||
lastProcessedIndex = searchReplaceMatcher.end()
|
||||
}
|
||||
|
||||
if (!foundSearchReplace) {
|
||||
val tolerantResult = tolerantScanSearchReplace(codeContent, language, filePath)
|
||||
if (tolerantResult != null) {
|
||||
addAll(tolerantResult.segments)
|
||||
lastProcessedIndex = tolerantResult.lastIndex
|
||||
foundSearchReplace = true
|
||||
}
|
||||
|
||||
val incompleteMatch = findIncompleteSearchReplace(codeContent, language, filePath)
|
||||
if (incompleteMatch != null) {
|
||||
addAll(incompleteMatch.segments)
|
||||
|
|
@ -152,7 +174,13 @@ class CompleteMessageParser : MessageParser {
|
|||
}
|
||||
|
||||
if (foundSearchReplace) {
|
||||
addCodeSegmentIfExists(codeContent, lastProcessedIndex, codeContent.length, language, filePath)
|
||||
addCodeSegmentIfExists(
|
||||
codeContent,
|
||||
lastProcessedIndex,
|
||||
codeContent.length,
|
||||
language,
|
||||
filePath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,12 +213,14 @@ class CompleteMessageParser : MessageParser {
|
|||
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
|
||||
))
|
||||
add(
|
||||
SearchReplace(
|
||||
search = searchContent,
|
||||
replace = replaceContent,
|
||||
language = language,
|
||||
filePath = filePath
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,12 +245,14 @@ class CompleteMessageParser : MessageParser {
|
|||
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
|
||||
))
|
||||
add(
|
||||
SearchReplace(
|
||||
search = searchContent,
|
||||
replace = replaceContent,
|
||||
language = language,
|
||||
filePath = filePath
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
IncompleteSearchReplaceResult(segments, incompleteMatcher.end())
|
||||
|
|
@ -236,4 +268,96 @@ class CompleteMessageParser : MessageParser {
|
|||
val segments: List<Segment>,
|
||||
val endIndex: Int
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerant scan for blocks with markers like <<<, ===, >>> (>=3 symbols).
|
||||
* Returns segments in order and the last processed index, so trailing code can be appended by caller.
|
||||
*/
|
||||
private fun tolerantScanSearchReplace(
|
||||
codeContent: String,
|
||||
language: String,
|
||||
filePath: String?
|
||||
): TolerantScanResult? {
|
||||
val segments = mutableListOf<Segment>()
|
||||
var cursor = 0
|
||||
var progressed = false
|
||||
|
||||
fun findNextLineMatching(regex: Regex, startPos: Int): Pair<Int, Int> {
|
||||
var pos = startPos
|
||||
val len = codeContent.length
|
||||
while (pos <= len) {
|
||||
val lineStart = pos
|
||||
if (pos >= len) return -1 to -1
|
||||
val nl = codeContent.indexOf('\n', pos)
|
||||
val lineEndExclusive = if (nl == -1) len else nl + 1
|
||||
val rawLine = codeContent.substring(lineStart, lineEndExclusive).trimEnd('\n', '\r')
|
||||
val trimmed = rawLine.trim()
|
||||
if (regex.matches(trimmed)) return lineStart to lineEndExclusive
|
||||
pos = lineEndExclusive
|
||||
}
|
||||
return -1 to -1
|
||||
}
|
||||
|
||||
while (cursor < codeContent.length) {
|
||||
val (startLineStart, startLineEnd) = findNextLineMatching(TOLERANT_SEARCH_START, cursor)
|
||||
if (startLineStart == -1) break
|
||||
|
||||
segments.addCodeSegmentIfExists(codeContent, cursor, startLineStart, language, filePath)
|
||||
|
||||
val (sepLineStart, sepLineEnd) = findNextLineMatching(TOLERANT_SEPARATOR, startLineEnd)
|
||||
if (sepLineStart == -1) {
|
||||
val search = codeContent.substring(startLineEnd, codeContent.length)
|
||||
if (search.isNotEmpty()) {
|
||||
segments.add(
|
||||
SearchReplace(
|
||||
search = search,
|
||||
replace = "",
|
||||
language = language,
|
||||
filePath = filePath
|
||||
)
|
||||
)
|
||||
cursor = codeContent.length
|
||||
progressed = true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
val (endLineStart, endLineEnd) = findNextLineMatching(TOLERANT_REPLACE_END, sepLineEnd)
|
||||
if (endLineStart == -1) {
|
||||
val search = codeContent.substring(startLineEnd, sepLineStart)
|
||||
val replace = codeContent.substring(sepLineEnd, codeContent.length)
|
||||
segments.add(
|
||||
SearchReplace(
|
||||
search = search,
|
||||
replace = replace,
|
||||
language = language,
|
||||
filePath = filePath
|
||||
)
|
||||
)
|
||||
cursor = codeContent.length
|
||||
progressed = true
|
||||
break
|
||||
}
|
||||
|
||||
val search = codeContent.substring(startLineEnd, sepLineStart)
|
||||
val replace = codeContent.substring(sepLineEnd, endLineStart)
|
||||
segments.add(
|
||||
SearchReplace(
|
||||
search = search,
|
||||
replace = replace,
|
||||
language = language,
|
||||
filePath = filePath
|
||||
)
|
||||
)
|
||||
cursor = endLineEnd
|
||||
progressed = true
|
||||
}
|
||||
|
||||
return if (progressed) TolerantScanResult(segments, cursor) else null
|
||||
}
|
||||
|
||||
private data class TolerantScanResult(
|
||||
val segments: List<Segment>,
|
||||
val lastIndex: Int
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ class SseMessageParser : MessageParser {
|
|||
const val NEWLINE = "\n"
|
||||
const val HEADER_DELIMITER = ":"
|
||||
const val HEADER_PARTS_LIMIT = 2
|
||||
|
||||
val SEARCH_START_REGEX = Regex("""^\s*<{3,}(\s*SEARCH.*)?$""", RegexOption.IGNORE_CASE)
|
||||
val SEPARATOR_REGEX = Regex("""^\s*={3,}\s*$""")
|
||||
val REPLACE_END_REGEX = Regex("""^\s*>{3,}(\s*REPLACE.*)?$""", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
private var parserState: ParserState = ParserState.Outside
|
||||
|
|
@ -119,7 +123,7 @@ class SseMessageParser : MessageParser {
|
|||
true
|
||||
}
|
||||
|
||||
line.trimStart().startsWith(SEARCH_MARKER) -> {
|
||||
isSearchStartLine(line) -> {
|
||||
// Emit accumulated code content before transitioning
|
||||
if (state.content.isNotEmpty()) {
|
||||
segments.add(Code(state.content, state.header.language, state.header.filePath))
|
||||
|
|
@ -148,7 +152,7 @@ class SseMessageParser : MessageParser {
|
|||
val line = buffer.substring(0, nlIdx)
|
||||
consumeFromBuffer(nlIdx + 1)
|
||||
|
||||
return if (line.trim() == SEPARATOR_MARKER) {
|
||||
return if (isSeparatorLine(line)) {
|
||||
segments.add(
|
||||
ReplaceWaiting(
|
||||
state.searchContent,
|
||||
|
|
@ -179,7 +183,7 @@ class SseMessageParser : MessageParser {
|
|||
consumeFromBuffer(nlIdx + 1)
|
||||
|
||||
return when {
|
||||
line.trim().startsWith(REPLACE_MARKER) -> {
|
||||
isReplaceEndLine(line) -> {
|
||||
segments.add(
|
||||
SearchReplace(
|
||||
search = state.searchContent,
|
||||
|
|
@ -318,11 +322,26 @@ class SseMessageParser : MessageParser {
|
|||
return if (parts.isNotEmpty()) {
|
||||
CodeHeader(
|
||||
language = parts.getOrNull(0) ?: "",
|
||||
filePath = parts.getOrNull(1)
|
||||
filePath = parts.getOrNull(1)?.trim()?.ifEmpty { null }
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
private fun isSearchStartLine(line: String): Boolean {
|
||||
val trimmed = line.trim()
|
||||
return trimmed.startsWith(SEARCH_MARKER) || SEARCH_START_REGEX.matches(trimmed)
|
||||
}
|
||||
|
||||
private fun isSeparatorLine(line: String): Boolean {
|
||||
val trimmed = line.trim()
|
||||
return trimmed == SEPARATOR_MARKER || SEPARATOR_REGEX.matches(trimmed)
|
||||
}
|
||||
|
||||
private fun isReplaceEndLine(line: String): Boolean {
|
||||
val trimmed = line.trim()
|
||||
return trimmed.startsWith(REPLACE_MARKER) || REPLACE_END_REGEX.matches(trimmed)
|
||||
}
|
||||
|
||||
private sealed class ParserState {
|
||||
object Outside : ParserState()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,196 +0,0 @@
|
|||
package ee.carlrobert.codegpt.ui
|
||||
|
||||
import com.intellij.ide.IdeBundle
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.openapi.actionSystem.ActionPlaces
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.event.SelectionEvent
|
||||
import com.intellij.openapi.editor.event.SelectionListener
|
||||
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
|
||||
import com.intellij.openapi.observable.properties.ObservableProperty
|
||||
import com.intellij.openapi.observable.util.not
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.openapi.ui.popup.util.MinimizeButton
|
||||
import com.intellij.ui.DocumentAdapter
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.ui.dsl.builder.AlignX
|
||||
import com.intellij.ui.dsl.builder.Cell
|
||||
import com.intellij.ui.dsl.builder.Row
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.ui.layout.ComponentPredicate
|
||||
import com.intellij.util.ui.AsyncProcessIcon
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.actions.editor.EditCodeSubmissionHandler
|
||||
import ee.carlrobert.codegpt.settings.models.ModelSettings
|
||||
import ee.carlrobert.codegpt.settings.models.SettingsModelComboBoxAction
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.event.KeyAdapter
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.event.DocumentEvent
|
||||
|
||||
data class ObservableProperties(
|
||||
val submitted: AtomicBooleanProperty = AtomicBooleanProperty(false),
|
||||
val accepted: AtomicBooleanProperty = AtomicBooleanProperty(false),
|
||||
val loading: AtomicBooleanProperty = AtomicBooleanProperty(false),
|
||||
)
|
||||
|
||||
class EditCodePopover(private val editor: Editor) {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val observableProperties = ObservableProperties()
|
||||
private val submissionHandler = EditCodeSubmissionHandler(editor, observableProperties)
|
||||
private val promptTextField = JBTextField("", 40).apply {
|
||||
emptyText.appendText(CodeGPTBundle.get("editCodePopover.textField.emptyText"))
|
||||
addKeyListener(object : KeyAdapter() {
|
||||
override fun keyPressed(e: KeyEvent) {
|
||||
if (e.keyCode == KeyEvent.VK_ENTER) {
|
||||
e.consume()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
private val popup = JBPopupFactory.getInstance()
|
||||
.createComponentPopupBuilder(
|
||||
createPopupPanel(),
|
||||
promptTextField
|
||||
)
|
||||
.setTitle(CodeGPTBundle.get("editCodePopover.title"))
|
||||
.setMovable(true)
|
||||
.setCancelKeyEnabled(true)
|
||||
.setCancelOnClickOutside(false)
|
||||
.setCancelOnWindowDeactivation(false)
|
||||
.setRequestFocus(true)
|
||||
.setCancelButton(MinimizeButton(IdeBundle.message("tooltip.hide")))
|
||||
.setCancelCallback {
|
||||
submissionHandler.handleReject()
|
||||
true
|
||||
}
|
||||
.createPopup()
|
||||
|
||||
fun show() {
|
||||
popup.showInBestPositionFor(editor)
|
||||
}
|
||||
|
||||
private fun createPopupPanel(): JPanel {
|
||||
return panel {
|
||||
row {
|
||||
cell(promptTextField)
|
||||
}
|
||||
row {
|
||||
comment(CodeGPTBundle.get("editCodePopover.textField.comment"))
|
||||
}
|
||||
row {
|
||||
button(
|
||||
CodeGPTBundle.get("editCodePopover.submitButton.title"),
|
||||
observableProperties.submitted.not(),
|
||||
)
|
||||
button(
|
||||
CodeGPTBundle.get("editCodePopover.followUpButton.title"),
|
||||
observableProperties.submitted,
|
||||
)
|
||||
button(CodeGPTBundle.get("editCodePopover.acceptButton.title")) {
|
||||
submissionHandler.handleAccept()
|
||||
popup.cancel()
|
||||
}
|
||||
.visibleIf(observableProperties.submitted)
|
||||
.enabledIf(observableProperties.loading.not())
|
||||
cell(AsyncProcessIcon("edit_code_spinner")).visibleIf(observableProperties.loading)
|
||||
link(CodeGPTBundle.get("shared.discard")) {
|
||||
submissionHandler.handleReject()
|
||||
popup.cancel()
|
||||
}
|
||||
.align(AlignX.RIGHT)
|
||||
.visibleIf(observableProperties.submitted)
|
||||
}
|
||||
separator()
|
||||
row {
|
||||
text(CodeGPTBundle.get("shared.escToCancel"))
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}
|
||||
cell(
|
||||
SettingsModelComboBoxAction(
|
||||
FeatureType.EDIT_CODE,
|
||||
ModelSettings.getInstance().getModelSelection(FeatureType.EDIT_CODE),
|
||||
{}
|
||||
).createCustomComponent(ActionPlaces.UNKNOWN)
|
||||
).align(AlignX.RIGHT)
|
||||
}
|
||||
}.apply {
|
||||
border = JBUI.Borders.empty(8, 8, 2, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Row.button(title: String, visibleIf: ObservableProperty<Boolean>): Cell<JButton> {
|
||||
val button = JButton(title).apply {
|
||||
putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
|
||||
addActionListener {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
return cell(button)
|
||||
.visibleIf(visibleIf)
|
||||
.enabledIf(
|
||||
EnabledButtonComponentPredicate(
|
||||
button,
|
||||
editor,
|
||||
promptTextField,
|
||||
observableProperties
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSubmit() {
|
||||
serviceScope.launch {
|
||||
submissionHandler.handleSubmit(promptTextField.text)
|
||||
promptTextField.text = ""
|
||||
promptTextField.emptyText.text =
|
||||
CodeGPTBundle.get("editCodePopover.textField.followUp.emptyText")
|
||||
}
|
||||
}
|
||||
|
||||
private class EnabledButtonComponentPredicate(
|
||||
private val button: JButton,
|
||||
private val editor: Editor,
|
||||
private val promptTextField: JBTextField,
|
||||
private val observableProperties: ObservableProperties
|
||||
) : ComponentPredicate() {
|
||||
override fun invoke(): Boolean {
|
||||
if (!editor.selectionModel.hasSelection()) {
|
||||
button.toolTipText = "Please select code to continue"
|
||||
}
|
||||
if (promptTextField.text.isEmpty()) {
|
||||
button.toolTipText = "Please enter a prompt to continue"
|
||||
}
|
||||
|
||||
return editor.selectionModel.hasSelection()
|
||||
&& promptTextField.text.isNotEmpty()
|
||||
&& observableProperties.loading.get().not()
|
||||
}
|
||||
|
||||
override fun addListener(listener: (Boolean) -> Unit) {
|
||||
promptTextField.document.addDocumentListener(object : DocumentAdapter() {
|
||||
override fun textChanged(e: DocumentEvent) {
|
||||
runInEdt { listener(invoke()) }
|
||||
}
|
||||
})
|
||||
editor.selectionModel.addSelectionListener(object : SelectionListener {
|
||||
override fun selectionChanged(e: SelectionEvent) {
|
||||
runInEdt { listener(invoke()) }
|
||||
}
|
||||
})
|
||||
observableProperties.loading.afterSet {
|
||||
runInEdt { listener(invoke()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
348
src/main/kotlin/ee/carlrobert/codegpt/ui/InlineEditPopover.kt
Normal file
348
src/main/kotlin/ee/carlrobert/codegpt/ui/InlineEditPopover.kt
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package ee.carlrobert.codegpt.ui
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.invokeLater
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.fileEditor.FileEditorManagerEvent
|
||||
import com.intellij.openapi.fileEditor.FileEditorManagerListener
|
||||
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.ui.awt.RelativePoint
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import ee.carlrobert.codegpt.ReferencedFile
|
||||
import ee.carlrobert.codegpt.inlineedit.InlineEditConversationManager
|
||||
import ee.carlrobert.codegpt.inlineedit.InlineEditSubmissionHandler
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.psistructure.PsiStructureProvider
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel
|
||||
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.DiagnosticsTagDetails
|
||||
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
|
||||
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
|
||||
import ee.carlrobert.codegpt.util.GitUtil
|
||||
import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Dimension
|
||||
import java.awt.Graphics
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
data class ObservableProperties(
|
||||
val submitted: AtomicBooleanProperty = AtomicBooleanProperty(false),
|
||||
val accepted: AtomicBooleanProperty = AtomicBooleanProperty(false),
|
||||
val loading: AtomicBooleanProperty = AtomicBooleanProperty(false),
|
||||
val hasPendingChanges: AtomicBooleanProperty = AtomicBooleanProperty(false),
|
||||
)
|
||||
|
||||
class InlineEditPopover(private var editor: Editor) : Disposable {
|
||||
|
||||
companion object {
|
||||
val POPOVER_KEY: Key<InlineEditPopover> = Key.create("InlineEditPopover")
|
||||
private val logger = thisLogger()
|
||||
}
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val observableProperties = ObservableProperties()
|
||||
private val tagManager = TagManager(this)
|
||||
private var changesAccepted = false
|
||||
private var submissionHandler = InlineEditSubmissionHandler(editor, observableProperties)
|
||||
|
||||
private val project = editor.project!!
|
||||
|
||||
private val psiStructureRepository = PsiStructureRepository(
|
||||
this,
|
||||
editor.project!!,
|
||||
tagManager,
|
||||
PsiStructureProvider(),
|
||||
CoroutineDispatchers()
|
||||
)
|
||||
|
||||
private val dummyTokensPanel = TotalTokensPanel(
|
||||
Conversation(),
|
||||
null,
|
||||
this,
|
||||
psiStructureRepository
|
||||
)
|
||||
|
||||
private val userInputPanel = UserInputPanel(
|
||||
project = editor.project!!,
|
||||
totalTokensPanel = dummyTokensPanel,
|
||||
parentDisposable = this,
|
||||
featureType = FeatureType.INLINE_EDIT,
|
||||
tagManager = tagManager,
|
||||
onSubmit = { text ->
|
||||
handleSubmit(text)
|
||||
},
|
||||
onStop = {
|
||||
submissionHandler.handleReject(clearConversation = false)
|
||||
},
|
||||
onAcceptAll = {
|
||||
editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.acceptAll()
|
||||
?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptAll()
|
||||
},
|
||||
onRejectAll = {
|
||||
val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)
|
||||
val renderer = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)
|
||||
|
||||
if (session != null) {
|
||||
session.rejectAll()
|
||||
submissionHandler.restorePreviousPrompt()
|
||||
} else if (renderer != null) {
|
||||
renderer.rejectAll()
|
||||
submissionHandler.restorePreviousPrompt()
|
||||
}
|
||||
},
|
||||
showModeSelector = false,
|
||||
withRemovableSelectedEditorTag = false
|
||||
).apply {
|
||||
isOpaque = true
|
||||
setInlineEditControlsVisible(false)
|
||||
setThinkingVisible(false)
|
||||
}
|
||||
|
||||
private val userInputWrapper = BorderLayoutPanel().apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty()
|
||||
add(userInputPanel, BorderLayout.CENTER)
|
||||
}.andTransparent()
|
||||
|
||||
private val mainContainer = object : JPanel() {
|
||||
init {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
background = null
|
||||
add(userInputWrapper)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics?) {
|
||||
}
|
||||
}
|
||||
|
||||
private val popup = JBPopupFactory.getInstance()
|
||||
.createComponentPopupBuilder(mainContainer, userInputPanel)
|
||||
.setMovable(true)
|
||||
.setResizable(true)
|
||||
.setCancelKeyEnabled(true)
|
||||
.setCancelOnClickOutside(false)
|
||||
.setCancelOnWindowDeactivation(false)
|
||||
.setRequestFocus(true)
|
||||
.setMinSize(Dimension(600, 80))
|
||||
.setShowBorder(false)
|
||||
.setShowShadow(false)
|
||||
.setAdText("")
|
||||
.setCancelCallback {
|
||||
if (!changesAccepted) {
|
||||
submissionHandler.handleReject(clearConversation = true)
|
||||
}
|
||||
true
|
||||
}
|
||||
.createPopup()
|
||||
|
||||
init {
|
||||
Disposer.register(popup, this)
|
||||
userInputPanel.requestFocus()
|
||||
|
||||
editor.putUserData(POPOVER_KEY, this)
|
||||
}
|
||||
|
||||
fun show() {
|
||||
val point = computePopupPoint(editor)
|
||||
popup.show(point)
|
||||
|
||||
invokeLater {
|
||||
userInputPanel.requestFocus()
|
||||
|
||||
SwingUtilities.getWindowAncestor(popup.content)?.let { window ->
|
||||
try {
|
||||
window.background = Color(0, 0, 0, 0)
|
||||
|
||||
if (window is javax.swing.JWindow) {
|
||||
window.contentPane.background = Color(0, 0, 0, 0)
|
||||
if (window.contentPane is JComponent) {
|
||||
(window.contentPane as JComponent).isOpaque = false
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to make window transparent: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
project.messageBus.connect(this).subscribe(
|
||||
FileEditorManagerListener.FILE_EDITOR_MANAGER,
|
||||
object : FileEditorManagerListener {
|
||||
override fun selectionChanged(event: FileEditorManagerEvent) {
|
||||
val newEditor =
|
||||
FileEditorManager.getInstance(project).selectedTextEditor ?: return
|
||||
if (newEditor === this@InlineEditPopover.editor) return
|
||||
attachToEditor(newEditor)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
serviceScope.cancel()
|
||||
editor.putUserData(POPOVER_KEY, null)
|
||||
}
|
||||
|
||||
fun onCompletionFinished() {
|
||||
runInEdt {
|
||||
userInputPanel.setSubmitEnabled(true)
|
||||
observableProperties.submitted.set(false)
|
||||
setThinkingVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun markChangesAsAccepted() {
|
||||
changesAccepted = true
|
||||
observableProperties.accepted.set(true)
|
||||
FileDocumentManager.getInstance().saveDocument(editor.document)
|
||||
}
|
||||
|
||||
fun setInlineEditControlsVisible(visible: Boolean) {
|
||||
userInputPanel.setInlineEditControlsVisible(visible)
|
||||
}
|
||||
|
||||
fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") {
|
||||
userInputPanel.setThinkingVisible(visible, text)
|
||||
}
|
||||
|
||||
fun restorePromptAndFocus(promptText: String) {
|
||||
runInEdt {
|
||||
userInputPanel.setTextAndFocus(promptText)
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerPromptRestoration() {
|
||||
submissionHandler.restorePreviousPrompt()
|
||||
}
|
||||
|
||||
private fun handleSubmit(text: String) {
|
||||
if (text.isNotEmpty()) {
|
||||
observableProperties.submitted.set(true)
|
||||
userInputPanel.setSubmitEnabled(false)
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val refs = collectSelectedReferencedFiles()
|
||||
val diff = try {
|
||||
GitUtil.getCurrentChanges(editor.project!!)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val conversationHistory = collectConversationHistory()
|
||||
val diagnosticsInfo = collectDiagnosticsInfo()
|
||||
submissionHandler.handleSubmit(
|
||||
text,
|
||||
refs,
|
||||
diff,
|
||||
conversationHistory,
|
||||
diagnosticsInfo
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error submitting inline edit", e)
|
||||
runInEdt {
|
||||
userInputPanel.setSubmitEnabled(true)
|
||||
observableProperties.submitted.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectConversationHistory(): List<Conversation> {
|
||||
val tags: Set<TagDetails> = tagManager.getTags()
|
||||
return tags
|
||||
.filter { it.selected && it is HistoryTagDetails }
|
||||
.map { (it as HistoryTagDetails).conversationId }
|
||||
.mapNotNull { ConversationTagProcessor.getConversation(it) }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private fun collectSelectedReferencedFiles(): List<ReferencedFile> {
|
||||
val tags: Set<TagDetails> = tagManager.getTags()
|
||||
val currentPath = editor.virtualFile?.path
|
||||
val selectedVfs = tags
|
||||
.filter { it.selected }
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is FileTagDetails -> it.virtualFile
|
||||
is EditorTagDetails -> it.virtualFile
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.filter { vf -> vf.path != currentPath }
|
||||
.distinctBy { it.path }
|
||||
|
||||
return selectedVfs.mapNotNull { v ->
|
||||
try {
|
||||
ReferencedFile.from(v)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectDiagnosticsInfo(): String? {
|
||||
val tags: Set<TagDetails> = tagManager.getTags()
|
||||
val diagnosticsTag =
|
||||
tags.firstOrNull { it.selected && it is DiagnosticsTagDetails } as? DiagnosticsTagDetails
|
||||
?: return null
|
||||
|
||||
val processor = TagProcessorFactory.getProcessor(project, diagnosticsTag)
|
||||
val stringBuilder = StringBuilder()
|
||||
processor.process(Message("", ""), stringBuilder)
|
||||
return stringBuilder.toString().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun computePopupPoint(targetEditor: Editor): RelativePoint {
|
||||
val editorComponent = targetEditor.component
|
||||
val popupWidth = 600
|
||||
val popupHeight = mainContainer.preferredSize.height.coerceAtLeast(150)
|
||||
|
||||
val editorWidth = editorComponent.width
|
||||
var x = (editorWidth - popupWidth) / 2
|
||||
if (x < 0) x = 0
|
||||
val paddingFromBottom = 20
|
||||
val y = editorComponent.height - popupHeight - paddingFromBottom
|
||||
return RelativePoint(editorComponent, java.awt.Point(x, y))
|
||||
}
|
||||
|
||||
private fun attachToEditor(newEditor: Editor) {
|
||||
val oldEx = this.editor as? EditorEx
|
||||
val newEx = newEditor as? EditorEx
|
||||
|
||||
this.editor.putUserData(POPOVER_KEY, null)
|
||||
this.editor = newEditor
|
||||
this.editor.putUserData(POPOVER_KEY, this)
|
||||
|
||||
InlineEditConversationManager.moveConversation(oldEx, newEx)
|
||||
|
||||
submissionHandler = InlineEditSubmissionHandler(newEditor, observableProperties)
|
||||
|
||||
val point = computePopupPoint(newEditor)
|
||||
popup.setLocation(point.screenPoint)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package ee.carlrobert.codegpt.ui.components
|
||||
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.*
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JComponent
|
||||
|
||||
/**
|
||||
* A small rounded "pill" button used in inline edit UIs.
|
||||
* Matches the visual feel of the Y/N badges shown in diff blocks.
|
||||
*/
|
||||
class BadgeChip(
|
||||
private val text: String,
|
||||
private val backgroundColor: JBColor,
|
||||
private val onClick: () -> Unit,
|
||||
private val fixedHeight: Int = JBUI.scale(18),
|
||||
private val horizontalPadding: Int = JBUI.scale(8),
|
||||
private val cornerRadius: Int = JBUI.scale(8),
|
||||
private val textColor: JBColor = JBColor(Color(0xDF, 0xE1, 0xE5), Color(0xDF, 0xE1, 0xE5))
|
||||
) : JComponent() {
|
||||
|
||||
init {
|
||||
cursor = Cursor(Cursor.HAND_CURSOR)
|
||||
toolTipText = text
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent?) {
|
||||
onClick()
|
||||
}
|
||||
})
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val fm = getFontMetrics(font)
|
||||
val w = fm.stringWidth(text) + horizontalPadding * 2
|
||||
return Dimension(w, fixedHeight)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g as Graphics2D
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
val r = Rectangle(0, 0, width, height)
|
||||
|
||||
g2.color = backgroundColor
|
||||
g2.fillRoundRect(r.x, r.y, r.width - 1, r.height - 1, cornerRadius, cornerRadius)
|
||||
g2.color = backgroundColor.darker()
|
||||
g2.drawRoundRect(r.x, r.y, r.width - 1, r.height - 1, cornerRadius, cornerRadius)
|
||||
|
||||
g2.font = font
|
||||
g2.color = textColor
|
||||
val fm = g2.fontMetrics
|
||||
val tx = (width - fm.stringWidth(text)) / 2
|
||||
val ty = (height - fm.height) / 2 + fm.ascent
|
||||
g2.drawString(text, tx, ty)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package ee.carlrobert.codegpt.ui.components
|
||||
|
||||
import com.intellij.openapi.actionSystem.KeyboardShortcut
|
||||
import com.intellij.openapi.keymap.KeymapManager
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.ui.JBColor
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import java.awt.Color
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
object InlineEditChips {
|
||||
|
||||
val GREEN = JBColor(Color(0x00, 0x99, 0x00), Color(0x00, 0x99, 0x00))
|
||||
val RED = JBColor(Color(0xD0, 0x36, 0x36), Color(0xD0, 0x36, 0x36))
|
||||
val TEXT = JBColor(Color(0xDF, 0xE1, 0xE5), Color(0xDF, 0xE1, 0xE5))
|
||||
|
||||
fun keyY(onClick: () -> Unit) = BadgeChip(
|
||||
currentShortcutLabel(
|
||||
actionId = "CodeGPT.AcceptCurrentInlineEdit",
|
||||
preferredKeyCode = KeyEvent.VK_Y,
|
||||
macFallback = "⌘Y",
|
||||
otherFallback = "Ctrl+Y"
|
||||
),
|
||||
GREEN,
|
||||
onClick,
|
||||
textColor = TEXT
|
||||
)
|
||||
|
||||
fun keyN(onClick: () -> Unit) = BadgeChip(
|
||||
currentShortcutLabel(
|
||||
actionId = "CodeGPT.RejectCurrentInlineEdit",
|
||||
preferredKeyCode = KeyEvent.VK_N,
|
||||
macFallback = "⌘N",
|
||||
otherFallback = "Ctrl+N"
|
||||
),
|
||||
RED,
|
||||
onClick,
|
||||
textColor = TEXT
|
||||
)
|
||||
|
||||
private fun currentShortcutLabel(
|
||||
actionId: String,
|
||||
preferredKeyCode: Int,
|
||||
macFallback: String,
|
||||
otherFallback: String
|
||||
): String {
|
||||
val fb = if (SystemInfo.isMac) macFallback else otherFallback
|
||||
return try {
|
||||
val keymap = KeymapManager.getInstance().activeKeymap
|
||||
val shortcuts = keymap.getShortcuts(actionId)
|
||||
val preferred = preferredShortcut(shortcuts, preferredKeyCode)
|
||||
?: shortcuts.firstOrNull() as? KeyboardShortcut
|
||||
if (preferred != null) keyStrokeToLabel(preferred.firstKeyStroke) else fb
|
||||
} catch (_: Exception) {
|
||||
fb
|
||||
}
|
||||
}
|
||||
|
||||
private fun preferredShortcut(
|
||||
shortcuts: Array<com.intellij.openapi.actionSystem.Shortcut>,
|
||||
keyCode: Int
|
||||
): KeyboardShortcut? {
|
||||
val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK)
|
||||
val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK)
|
||||
return shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs } as? KeyboardShortcut
|
||||
?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs } as? KeyboardShortcut
|
||||
}
|
||||
|
||||
private fun keyStrokeToLabel(ks: KeyStroke): String {
|
||||
return if (SystemInfo.isMac) {
|
||||
buildString {
|
||||
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) append('⌘')
|
||||
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) append('⇧')
|
||||
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) append('⌥')
|
||||
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) append('⌃')
|
||||
append(KeyEvent.getKeyText(ks.keyCode).uppercase())
|
||||
}
|
||||
} else {
|
||||
val parts = mutableListOf<String>()
|
||||
if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) parts.add("Ctrl")
|
||||
if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) parts.add("Shift")
|
||||
if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) parts.add("Alt")
|
||||
if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) parts.add("Meta")
|
||||
parts.add(KeyEvent.getKeyText(ks.keyCode).uppercase())
|
||||
parts.joinToString("+")
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptAll(onClick: () -> Unit) =
|
||||
BadgeChip(CodeGPTBundle.get("shared.acceptAll"), GREEN, onClick, textColor = TEXT)
|
||||
|
||||
fun rejectAll(onClick: () -> Unit) =
|
||||
BadgeChip(CodeGPTBundle.get("shared.rejectAll"), RED, onClick, textColor = TEXT)
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import com.intellij.openapi.wm.ToolWindowManager
|
|||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
||||
|
|
@ -60,6 +61,8 @@ class PromptTextField(
|
|||
isOneLineMode = false
|
||||
IS_PROMPT_TEXT_FIELD_DOCUMENT.set(document, true)
|
||||
setPlaceholder(CodeGPTBundle.get("toolwindow.chat.textArea.emptyText"))
|
||||
|
||||
putClientProperty(UIUtil.HIDE_EDITOR_FROM_DATA_CONTEXT_PROPERTY, true)
|
||||
}
|
||||
|
||||
override fun onEditorAdded(editor: Editor) {
|
||||
|
|
@ -85,6 +88,13 @@ class PromptTextField(
|
|||
}
|
||||
}
|
||||
|
||||
fun setTextAndFocus(text: String) {
|
||||
runInEdt {
|
||||
this.text = text
|
||||
requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showGroupLookup() {
|
||||
val lookupItems = searchManager.getDefaultGroups()
|
||||
.map { it.createLookupElement() }
|
||||
|
|
@ -349,6 +359,11 @@ class PromptTextField(
|
|||
}
|
||||
|
||||
private fun adjustHeight(editor: EditorEx) {
|
||||
val toolWindow = project.service<ToolWindowManager>().getToolWindow("ProxyAI")
|
||||
if (toolWindow == null || !toolWindow.component.isAncestorOf(this)) {
|
||||
return
|
||||
}
|
||||
|
||||
val contentHeight =
|
||||
editor.contentComponent.preferredSize.height + PromptTextFieldConstants.HEIGHT_PADDING
|
||||
val maxHeight = JBUI.scale(getToolWindowHeight() / 2)
|
||||
|
|
|
|||
|
|
@ -23,17 +23,25 @@ class PromptTextFieldEventDispatcher(
|
|||
override fun dispatch(e: AWTEvent): Boolean {
|
||||
if ((e is KeyEvent || e is MouseEvent) && findParent() is PromptTextField) {
|
||||
if (e is KeyEvent) {
|
||||
if (e.id == KeyEvent.KEY_PRESSED && e.keyCode == KeyEvent.VK_BACK_SPACE) {
|
||||
onBackSpace()
|
||||
}
|
||||
if (e.id == KeyEvent.KEY_PRESSED) {
|
||||
when (e.keyCode) {
|
||||
KeyEvent.VK_BACK_SPACE -> {
|
||||
if (!handleBackspace(e)) {
|
||||
onBackSpace()
|
||||
}
|
||||
}
|
||||
|
||||
if (e.id == KeyEvent.KEY_PRESSED && e.keyCode == KeyEvent.VK_ENTER) {
|
||||
if (e.isShiftDown) {
|
||||
handleShiftEnter(e)
|
||||
} else if (e.modifiersEx and InputEvent.ALT_DOWN_MASK == 0
|
||||
&& e.modifiersEx and InputEvent.CTRL_DOWN_MASK == 0
|
||||
) {
|
||||
onSubmit(e)
|
||||
KeyEvent.VK_DELETE -> handleDelete(e)
|
||||
KeyEvent.VK_A -> if (e.isControlDown || e.isMetaDown) handleSelectAll(e)
|
||||
KeyEvent.VK_ENTER -> {
|
||||
if (e.isShiftDown) {
|
||||
handleShiftEnter(e)
|
||||
} else if (e.modifiersEx and InputEvent.ALT_DOWN_MASK == 0
|
||||
&& e.modifiersEx and InputEvent.CTRL_DOWN_MASK == 0
|
||||
) {
|
||||
onSubmit(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,4 +80,82 @@ class PromptTextFieldEventDispatcher(
|
|||
e.consume()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSelectAll(e: KeyEvent) {
|
||||
val parent = findParent()
|
||||
if (parent is PromptTextField) {
|
||||
parent.editor?.let { editor ->
|
||||
editor.selectionModel.setSelection(0, editor.document.textLength)
|
||||
e.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDelete(e: KeyEvent) {
|
||||
val parent = findParent()
|
||||
if (parent is PromptTextField) {
|
||||
parent.editor?.let { editor ->
|
||||
runUndoTransparentWriteAction {
|
||||
val document = editor.document
|
||||
val caretModel = editor.caretModel
|
||||
val selectionModel = editor.selectionModel
|
||||
|
||||
if (selectionModel.hasSelection()) {
|
||||
document.deleteString(
|
||||
selectionModel.selectionStart,
|
||||
selectionModel.selectionEnd
|
||||
)
|
||||
} else {
|
||||
val offset = caretModel.offset
|
||||
if (offset < document.textLength) {
|
||||
document.deleteString(offset, offset + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackspace(e: KeyEvent): Boolean {
|
||||
val parent = findParent()
|
||||
if (parent is PromptTextField) {
|
||||
parent.editor?.let { editor ->
|
||||
val selectionModel = editor.selectionModel
|
||||
if (selectionModel.hasSelection()) {
|
||||
runUndoTransparentWriteAction {
|
||||
editor.document.deleteString(
|
||||
selectionModel.selectionStart,
|
||||
selectionModel.selectionEnd
|
||||
)
|
||||
}
|
||||
e.consume()
|
||||
return true
|
||||
} else if (e.isControlDown || e.isMetaDown) {
|
||||
runUndoTransparentWriteAction {
|
||||
val document = editor.document
|
||||
val caretModel = editor.caretModel
|
||||
val offset = caretModel.offset
|
||||
if (offset > 0) {
|
||||
val text = document.text
|
||||
var wordStart = offset - 1
|
||||
|
||||
while (wordStart > 0 && Character.isWhitespace(text[wordStart])) {
|
||||
wordStart--
|
||||
}
|
||||
|
||||
while (wordStart > 0 && !Character.isWhitespace(text[wordStart - 1])) {
|
||||
wordStart--
|
||||
}
|
||||
|
||||
document.deleteString(wordStart, offset)
|
||||
}
|
||||
}
|
||||
e.consume()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -369,4 +369,4 @@ class DiagnosticsTagProcessor(
|
|||
else -> 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ import com.intellij.ui.dsl.builder.AlignX
|
|||
import com.intellij.ui.dsl.builder.RightGap
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.IconUtil
|
||||
import com.intellij.util.ui.AsyncProcessIcon
|
||||
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.ReferencedFile
|
||||
|
|
@ -30,6 +32,7 @@ import ee.carlrobert.codegpt.settings.service.ServiceType
|
|||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.ModelComboBoxAction
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel
|
||||
import ee.carlrobert.codegpt.ui.IconActionButton
|
||||
import ee.carlrobert.codegpt.ui.components.InlineEditChips
|
||||
import ee.carlrobert.codegpt.ui.dnd.FileDragAndDrop
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.UserInputHeaderPanel
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.*
|
||||
|
|
@ -41,16 +44,46 @@ import java.awt.*
|
|||
import java.awt.geom.Area
|
||||
import java.awt.geom.Rectangle2D
|
||||
import java.awt.geom.RoundRectangle2D
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
class UserInputPanel(
|
||||
class UserInputPanel @JvmOverloads constructor(
|
||||
private val project: Project,
|
||||
private val totalTokensPanel: TotalTokensPanel,
|
||||
parentDisposable: Disposable,
|
||||
featureType: FeatureType,
|
||||
private val tagManager: TagManager,
|
||||
private val onSubmit: (String) -> Unit,
|
||||
private val onStop: () -> Unit
|
||||
) : JPanel(BorderLayout()) {
|
||||
private val onStop: () -> Unit,
|
||||
private val onAcceptAll: (() -> Unit)? = null,
|
||||
private val onRejectAll: (() -> Unit)? = null,
|
||||
private val showModeSelector: Boolean = true,
|
||||
withRemovableSelectedEditorTag: Boolean = false,
|
||||
) : BorderLayoutPanel() {
|
||||
|
||||
constructor(
|
||||
project: Project,
|
||||
totalTokensPanel: TotalTokensPanel,
|
||||
parentDisposable: Disposable,
|
||||
featureType: FeatureType,
|
||||
tagManager: TagManager,
|
||||
onSubmit: (String) -> Unit,
|
||||
onStop: () -> Unit,
|
||||
showModeSelector: Boolean,
|
||||
withRemovableSelectedEditorTag: Boolean
|
||||
) : this(
|
||||
project,
|
||||
totalTokensPanel,
|
||||
parentDisposable,
|
||||
featureType,
|
||||
tagManager,
|
||||
onSubmit,
|
||||
onStop,
|
||||
null,
|
||||
null,
|
||||
showModeSelector,
|
||||
withRemovableSelectedEditorTag
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val CORNER_RADIUS = 16
|
||||
|
|
@ -60,12 +93,12 @@ class UserInputPanel(
|
|||
private val disposableCoroutineScope = DisposableCoroutineScope()
|
||||
private val promptTextField =
|
||||
PromptTextField(
|
||||
project,
|
||||
tagManager,
|
||||
::updateUserTokens,
|
||||
::handleBackSpace,
|
||||
::handleLookupAdded,
|
||||
::handleSubmit,
|
||||
project = project,
|
||||
tagManager = tagManager,
|
||||
onTextChanged = ::updateUserTokens,
|
||||
onBackSpace = ::handleBackSpace,
|
||||
onLookupAdded = ::handleLookupAdded,
|
||||
onSubmit = ::handleSubmit,
|
||||
onFilesDropped = { files ->
|
||||
includeFiles(files.toMutableList())
|
||||
totalTokensPanel.updateReferencedFilesTokens(files.map { ReferencedFile.from(it).fileContent() })
|
||||
|
|
@ -76,8 +109,28 @@ class UserInputPanel(
|
|||
project,
|
||||
tagManager,
|
||||
totalTokensPanel,
|
||||
promptTextField
|
||||
promptTextField,
|
||||
withRemovableSelectedEditorTag
|
||||
)
|
||||
|
||||
private val acceptChip =
|
||||
InlineEditChips.acceptAll { onAcceptAll?.invoke() }.apply { isVisible = false }
|
||||
private val rejectChip =
|
||||
InlineEditChips.rejectAll { onRejectAll?.invoke() }.apply { isVisible = false }
|
||||
private var inlineEditControls: List<javax.swing.JComponent> = listOf(acceptChip, rejectChip)
|
||||
|
||||
private val thinkingIcon = AsyncProcessIcon("inline-edit-thinking").apply { isVisible = false }
|
||||
private val thinkingLabel = javax.swing.JLabel("Thinking…").apply {
|
||||
foreground = service<EditorColorsManager>().globalScheme.defaultForeground
|
||||
isVisible = false
|
||||
}
|
||||
private val thinkingPanel =
|
||||
JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)).apply {
|
||||
isOpaque = false
|
||||
add(thinkingIcon)
|
||||
add(thinkingLabel)
|
||||
isVisible = false
|
||||
}
|
||||
private val submitButton = IconActionButton(
|
||||
object : AnAction(
|
||||
CodeGPTBundle.get("smartTextPane.submitButton.title"),
|
||||
|
|
@ -104,6 +157,9 @@ class UserInputPanel(
|
|||
).apply { isEnabled = false }
|
||||
private val imageActionSupported = AtomicBooleanProperty(isImageActionSupported())
|
||||
|
||||
private lateinit var modelComboBoxComponent: JComponent
|
||||
private var searchReplaceToggleComponent: JComponent? = null
|
||||
|
||||
val text: String
|
||||
get() = promptTextField.text
|
||||
|
||||
|
|
@ -115,7 +171,7 @@ class UserInputPanel(
|
|||
|
||||
init {
|
||||
setupDisposables(parentDisposable)
|
||||
setupLayout()
|
||||
setupLayout(featureType)
|
||||
addSelectedEditorContent()
|
||||
FileDragAndDrop.install(this) { files ->
|
||||
includeFiles(files.toMutableList())
|
||||
|
|
@ -128,21 +184,18 @@ class UserInputPanel(
|
|||
Disposer.register(parentDisposable, promptTextField)
|
||||
}
|
||||
|
||||
private fun setupLayout() {
|
||||
private fun setupLayout(featureType: FeatureType) {
|
||||
background = service<EditorColorsManager>().globalScheme.defaultBackground
|
||||
add(userInputHeaderPanel, BorderLayout.NORTH)
|
||||
add(promptTextField, BorderLayout.CENTER)
|
||||
add(createFooterPanel(), BorderLayout.SOUTH)
|
||||
addToTop(userInputHeaderPanel)
|
||||
addToCenter(promptTextField)
|
||||
addToBottom(createFooterPanel(featureType))
|
||||
}
|
||||
|
||||
private fun addSelectedEditorContent() {
|
||||
EditorUtil.getSelectedEditor(project)?.let { editor ->
|
||||
if (EditorUtil.hasSelection(editor)) {
|
||||
tagManager.addTag(
|
||||
EditorSelectionTagDetails(
|
||||
editor.virtualFile,
|
||||
editor.selectionModel
|
||||
)
|
||||
EditorSelectionTagDetails(editor.virtualFile, editor.selectionModel)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -222,6 +275,10 @@ class UserInputPanel(
|
|||
}
|
||||
}
|
||||
|
||||
fun setTextAndFocus(text: String) {
|
||||
promptTextField.setTextAndFocus(text)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
|
|
@ -295,7 +352,11 @@ class UserInputPanel(
|
|||
|
||||
private fun handleBackSpace() {
|
||||
if (text.isEmpty()) {
|
||||
userInputHeaderPanel.getLastTag()?.let { tagManager.remove(it) }
|
||||
userInputHeaderPanel.getLastTag()?.let { last ->
|
||||
if (last.isRemovable) {
|
||||
tagManager.remove(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,16 +374,25 @@ class UserInputPanel(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createFooterPanel(): JPanel {
|
||||
val currentService = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
|
||||
private fun createFooterPanel(featureType: FeatureType): JPanel {
|
||||
val currentService =
|
||||
ModelSelectionService.getInstance().getServiceForFeature(featureType)
|
||||
val modelComboBox = ModelComboBoxAction(
|
||||
project,
|
||||
{ imageActionSupported.set(isImageActionSupported()) },
|
||||
currentService
|
||||
currentService,
|
||||
ServiceType.entries,
|
||||
true,
|
||||
featureType
|
||||
).createCustomComponent(ActionPlaces.UNKNOWN)
|
||||
modelComboBoxComponent = modelComboBox
|
||||
|
||||
val searchReplaceToggle =
|
||||
val searchReplaceToggle = if (showModeSelector) {
|
||||
SearchReplaceToggleAction(this).createCustomComponent(ActionPlaces.UNKNOWN)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
searchReplaceToggleComponent = searchReplaceToggle
|
||||
|
||||
return panel {
|
||||
twoColumnsRow(
|
||||
|
|
@ -330,8 +400,13 @@ class UserInputPanel(
|
|||
panel {
|
||||
row {
|
||||
cell(modelComboBox).gap(RightGap.SMALL)
|
||||
cell(createToolbarSeparator()).gap(RightGap.SMALL)
|
||||
cell(searchReplaceToggle)
|
||||
cell(thinkingPanel).gap(RightGap.SMALL)
|
||||
cell(acceptChip).gap(RightGap.SMALL)
|
||||
cell(rejectChip).gap(RightGap.SMALL)
|
||||
if (showModeSelector) {
|
||||
cell(createToolbarSeparator()).gap(RightGap.SMALL)
|
||||
cell(searchReplaceToggle!!)
|
||||
}
|
||||
}
|
||||
}.align(AlignX.LEFT)
|
||||
},
|
||||
|
|
@ -346,10 +421,27 @@ class UserInputPanel(
|
|||
}.andTransparent()
|
||||
}
|
||||
|
||||
fun setInlineEditControlsVisible(visible: Boolean) {
|
||||
inlineEditControls.forEach { it.isVisible = visible }
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
|
||||
fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") {
|
||||
thinkingLabel.text = text
|
||||
thinkingIcon.isVisible = visible
|
||||
thinkingLabel.isVisible = visible
|
||||
thinkingPanel.isVisible = visible
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun isImageActionSupported(): Boolean {
|
||||
val currentModel = ModelSelectionService.getInstance().getModelForFeature(FeatureType.CHAT)
|
||||
val currentService = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
|
||||
|
||||
val currentService =
|
||||
ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
|
||||
|
||||
return when (currentService) {
|
||||
ServiceType.CUSTOM_OPENAI,
|
||||
ServiceType.ANTHROPIC,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ class UserInputHeaderPanel(
|
|||
private val project: Project,
|
||||
private val tagManager: TagManager,
|
||||
private val totalTokensPanel: TotalTokensPanel,
|
||||
private val promptTextField: PromptTextField
|
||||
private val promptTextField: PromptTextField,
|
||||
private val withRemovableSelectedEditorTag: Boolean
|
||||
) : JPanel(WrapLayout(FlowLayout.LEFT, 4, 4)), TagManagerListener {
|
||||
|
||||
companion object {
|
||||
|
|
@ -171,7 +172,7 @@ class UserInputHeaderPanel(
|
|||
private fun addInitialTags() {
|
||||
val selectedFile = getSelectedEditor(project)?.virtualFile
|
||||
if (selectedFile != null) {
|
||||
tagManager.addTag(EditorTagDetails(selectedFile))
|
||||
tagManager.addTag(EditorTagDetails(selectedFile, isRemovable = withRemovableSelectedEditorTag))
|
||||
}
|
||||
|
||||
EditorUtil.getOpenLocalFiles(project)
|
||||
|
|
@ -226,7 +227,8 @@ class UserInputHeaderPanel(
|
|||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
PaintUtil.drawRoundedBackground(g, this, true)
|
||||
val selectedVisually = isEnabled
|
||||
PaintUtil.drawRoundedBackground(g, this, selectedVisually)
|
||||
super.paintComponent(g)
|
||||
}
|
||||
}
|
||||
|
|
@ -260,12 +262,27 @@ class UserInputHeaderPanel(
|
|||
private inner class FileSelectionListener : FileEditorManagerListener {
|
||||
override fun selectionChanged(event: FileEditorManagerEvent) {
|
||||
event.newFile?.let { newFile ->
|
||||
val containsTag = tagManager.getTags()
|
||||
.none { it is EditorTagDetails && it.virtualFile == newFile }
|
||||
if (containsTag) {
|
||||
tagManager.addTag(EditorTagDetails(newFile).apply { selected = false })
|
||||
val hasTag = tagManager.getTags()
|
||||
.any { it is EditorTagDetails && it.virtualFile == newFile }
|
||||
if (!hasTag) {
|
||||
tagManager.addTag(EditorTagDetails(newFile, isRemovable = false))
|
||||
} else {
|
||||
tagManager.getTags()
|
||||
.filterIsInstance<EditorTagDetails>()
|
||||
.firstOrNull { it.virtualFile == newFile && it.isRemovable }?.let { existing ->
|
||||
tagManager.remove(existing)
|
||||
tagManager.addTag(EditorTagDetails(newFile, isRemovable = false))
|
||||
}
|
||||
}
|
||||
|
||||
tagManager.getTags()
|
||||
.filterIsInstance<EditorTagDetails>()
|
||||
.filter { it.virtualFile != newFile && !it.isRemovable }
|
||||
.forEach { prev ->
|
||||
tagManager.remove(prev)
|
||||
tagManager.addTag(EditorTagDetails(prev.virtualFile).apply { selected = false })
|
||||
}
|
||||
|
||||
emptyText.isVisible = false
|
||||
}
|
||||
}
|
||||
|
|
@ -340,6 +357,7 @@ class UserInputHeaderPanel(
|
|||
|
||||
override fun show(invoker: Component, x: Int, y: Int) {
|
||||
if (invoker is TagPanel) {
|
||||
if (!invoker.isEnabled) return
|
||||
val components = this@UserInputHeaderPanel.components.filterIsInstance<TagPanel>()
|
||||
val currentIndex = components.indexOf(invoker)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ sealed class TagDetails(
|
|||
val name: String,
|
||||
val icon: Icon? = null,
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val createdOn: Long = System.currentTimeMillis()
|
||||
val createdOn: Long = System.currentTimeMillis(),
|
||||
val isRemovable: Boolean = true
|
||||
) {
|
||||
|
||||
var selected: Boolean = true
|
||||
|
|
@ -30,8 +31,8 @@ sealed class TagDetails(
|
|||
}
|
||||
}
|
||||
|
||||
class EditorTagDetails(val virtualFile: VirtualFile) :
|
||||
TagDetails(virtualFile.name, virtualFile.fileType.icon) {
|
||||
class EditorTagDetails(val virtualFile: VirtualFile, isRemovable: Boolean = true) :
|
||||
TagDetails(virtualFile.name, virtualFile.fileType.icon, isRemovable = isRemovable) {
|
||||
|
||||
private val type: String = "EditorTagDetails"
|
||||
|
||||
|
|
@ -134,4 +135,4 @@ class EmptyTagDetails : TagDetails("")
|
|||
class CodeAnalyzeTagDetails : TagDetails("Code Analyze", AllIcons.Actions.DependencyAnalyzer)
|
||||
|
||||
data class DiagnosticsTagDetails(val virtualFile: VirtualFile) :
|
||||
TagDetails("${virtualFile.name} Problems", AllIcons.General.InspectionsEye)
|
||||
TagDetails("${virtualFile.name} Problems", AllIcons.General.InspectionsEye)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ abstract class TagPanel(
|
|||
|
||||
private val label = TagLabel(tagDetails.name, tagDetails.icon, tagDetails.selected)
|
||||
private val closeButton = CloseButton {
|
||||
isVisible = isSelected
|
||||
isVisible = isSelected && tagDetails.isRemovable
|
||||
onClose()
|
||||
}
|
||||
private var isRevertingSelection = false
|
||||
|
|
@ -41,7 +41,7 @@ abstract class TagPanel(
|
|||
abstract fun onClose()
|
||||
|
||||
fun update(text: String, icon: Icon? = null) {
|
||||
closeButton.isVisible = isSelected
|
||||
closeButton.isVisible = isSelected && tagDetails.isRemovable
|
||||
label.update(text, icon, isSelected)
|
||||
revalidate()
|
||||
repaint()
|
||||
|
|
@ -66,7 +66,7 @@ abstract class TagPanel(
|
|||
border = JBUI.Borders.empty(2, 6)
|
||||
cursor = Cursor(Cursor.HAND_CURSOR)
|
||||
isSelected = tagDetails.selected
|
||||
closeButton.isVisible = isSelected
|
||||
closeButton.isVisible = isSelected && tagDetails.isRemovable
|
||||
|
||||
val gbc = GridBagConstraints().apply {
|
||||
gridx = 0
|
||||
|
|
@ -90,7 +90,7 @@ abstract class TagPanel(
|
|||
isRevertingSelection = false
|
||||
}
|
||||
|
||||
closeButton.isVisible = isSelected
|
||||
closeButton.isVisible = isSelected && tagDetails.isRemovable
|
||||
tagDetails.selected = isSelected
|
||||
tagManager.notifySelectionChanged(tagDetails)
|
||||
label.update(isSelected)
|
||||
|
|
|
|||
|
|
@ -107,7 +107,8 @@
|
|||
<statusBarWidgetFactory order="first" id="ee.carlrobert.codegpt.statusbar.widget"
|
||||
implementation="ee.carlrobert.codegpt.statusbar.CodeGPTStatusBarWidgetFactory"/>
|
||||
<nameSuggestionProvider implementation="ee.carlrobert.codegpt.refactorings.DefaultNameSuggestionProvider"/>
|
||||
</extensions>
|
||||
</extensions>
|
||||
|
||||
|
||||
<resource-bundle>messages.codegpt</resource-bundle>
|
||||
|
||||
|
|
@ -133,6 +134,13 @@
|
|||
<keyboard-shortcut first-keystroke="ctrl shift ENTER" keymap="$default"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
id="codegpt.acceptInlineEdit"
|
||||
text="Accept Inline Edit"
|
||||
class="ee.carlrobert.codegpt.actions.editor.AcceptInlineEditAction">
|
||||
<keyboard-shortcut first-keystroke="ctrl ENTER" keymap="$default"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
id="codegpt.acceptNextInlayWord"
|
||||
text="Apply next word"
|
||||
|
|
@ -159,10 +167,10 @@
|
|||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt shift N" replace-all="true"/>
|
||||
</action>
|
||||
<action
|
||||
id="CodeGPT.ContextMenuEditCodeAction"
|
||||
text="Edit Code"
|
||||
description="Edit code from editor's context menu"
|
||||
class="ee.carlrobert.codegpt.actions.editor.EditCodeContextMenuAction">
|
||||
id="CodeGPT.ContextMenuInlineEditAction"
|
||||
text="Inline Edit"
|
||||
description="Edit code inline from editor's context menu"
|
||||
class="ee.carlrobert.codegpt.actions.editor.InlineEditContextMenuAction">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift K" replace-all="true"/>
|
||||
</action>
|
||||
<action
|
||||
|
|
@ -217,10 +225,10 @@
|
|||
|
||||
<group id="CodeGPT.FloatingCodeToolbarMenuRootGroup">
|
||||
<action
|
||||
id="CodeGPT.FloatingMenuEditCodeAction"
|
||||
text="Edit Code"
|
||||
description="Edit code from editor's floating menu"
|
||||
class="ee.carlrobert.codegpt.actions.editor.EditCodeFloatingMenuAction">
|
||||
id="CodeGPT.FloatingMenuInlineEditAction"
|
||||
text="Inline Edit"
|
||||
description="Edit code inline from editor's floating menu"
|
||||
class="ee.carlrobert.codegpt.actions.editor.InlineEditFloatingMenuAction">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift K" replace-all="true"/>
|
||||
</action>
|
||||
<add-to-group
|
||||
|
|
@ -237,10 +245,54 @@
|
|||
description="Adds the selected text to the ProxyAI context">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift I" replace-all="true"/>
|
||||
|
||||
<add-to-group group-id="action.editor.group.EditorActionGroup" anchor="after"
|
||||
relative-to-action="CodeGPT.NewChat"/>
|
||||
<add-to-group group-id="CodeGPT.FloatingCodeToolbarMenuRootGroup" anchor="after"
|
||||
relative-to-action="CodeGPT.FloatingMenuEditCodeAction"/>
|
||||
<add-to-group group-id="action.editor.group.EditorActionGroup" anchor="after" relative-to-action="CodeGPT.NewChat"/>
|
||||
<add-to-group group-id="CodeGPT.FloatingCodeToolbarMenuRootGroup" anchor="after" relative-to-action="CodeGPT.FloatingMenuInlineEditAction"/>
|
||||
</action>
|
||||
|
||||
<!-- Inline Edit Keyboard Shortcuts -->
|
||||
<action
|
||||
id="CodeGPT.AcceptAllInlineEdit"
|
||||
class="ee.carlrobert.codegpt.inlineedit.AcceptAllInlineEditAction"
|
||||
text="Accept All Inline Edit Changes"
|
||||
description="Accept all changes in the Inline Edit diff view">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift ENTER" replace-all="true"/>
|
||||
<add-to-group group-id="EditorActions" anchor="last"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
id="CodeGPT.RejectAllInlineEdit"
|
||||
class="ee.carlrobert.codegpt.inlineedit.RejectAllInlineEditAction"
|
||||
text="Reject All Inline Edit Changes"
|
||||
description="Reject all changes in the Inline Edit diff view">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift BACK_SPACE" replace-all="true"/>
|
||||
<add-to-group group-id="EditorActions" anchor="last"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
id="CodeGPT.RejectInlineEdit"
|
||||
class="ee.carlrobert.codegpt.inlineedit.RejectInlineEditAction"
|
||||
text="Reject Inline Edit Changes"
|
||||
description="Reject changes in the Inline Edit diff view">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ESCAPE" replace-all="true"/>
|
||||
<add-to-group group-id="EditorActions" anchor="last"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
id="CodeGPT.AcceptCurrentInlineEdit"
|
||||
class="ee.carlrobert.codegpt.inlineedit.AcceptCurrentInlineEditAction"
|
||||
text="Accept Current Inline Edit Change"
|
||||
description="Accept the current or next pending inline edit change">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl ENTER" replace-all="true"/>
|
||||
<add-to-group group-id="EditorActions" anchor="last"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
id="CodeGPT.RejectCurrentInlineEdit"
|
||||
class="ee.carlrobert.codegpt.inlineedit.RejectCurrentInlineEditAction"
|
||||
text="Reject Current Inline Edit Change"
|
||||
description="Reject the current or next pending inline edit change">
|
||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl BACK_SPACE" replace-all="true"/>
|
||||
<add-to-group group-id="EditorActions" anchor="last"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
|
|
@ -312,4 +364,4 @@
|
|||
class="ee.carlrobert.codegpt.actions.ReviewChangesAction"/>
|
||||
</group>
|
||||
</actions>
|
||||
</idea-plugin>
|
||||
</idea-plugin>
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ notification.compilationError.okLabel=Resolve errors
|
|||
notification.completionError.description=Completion failed:<br/>%s
|
||||
statusBar.widget.tooltip=ProxyAI: Status
|
||||
shared.acceptAll=Accept All
|
||||
shared.rejectAll=Reject All
|
||||
shared.promptTemplate=Prompt template:
|
||||
shared.infillPromptTemplate=Infill template:
|
||||
shared.apiVersion=API version:
|
||||
|
|
@ -404,3 +405,9 @@ conversation.status.sortedBy=Sorted by: {0}
|
|||
conversation.deleteConfirmation.message=Are you sure you want to delete this conversation?
|
||||
conversation.deleteConfirmation.title=Delete Conversation
|
||||
chat.message.welcome=Hi <strong>{0}</strong>, I'm ProxyAI! You can ask me anything, but most people request help with their code. Here are a few examples of what you can ask me:
|
||||
inlineEdit.status.preparingReplacement=Preparing replacement...
|
||||
inlineEdit.tooltip.ready=Ready to apply changes
|
||||
inlineEdit.tooltip.searching=Searching for pattern...
|
||||
inlineEdit.hint.searchingFor=Searching for: {0}
|
||||
inlineEdit.status.waiting=Waiting for model response…
|
||||
inlineEdit.status.noChanges=No applicable changes found
|
||||
|
|
|
|||
|
|
@ -1,17 +1,63 @@
|
|||
You are a code modification assistant. Your task is to modify the provided code based on the user's instructions.
|
||||
You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file.
|
||||
|
||||
Rules:
|
||||
1. Return only the modified code, with no additional text or explanations.
|
||||
2. The first character of your response must be the first character of the code.
|
||||
3. The last character of your response must be the last character of the code.
|
||||
4. NEVER use triple backticks (```) or any other markdown formatting in your response.
|
||||
5. Do not use any code block indicators, syntax highlighting markers, or any other formatting characters.
|
||||
6. Present the code exactly as it would appear in a plain text editor, preserving all whitespace, indentation, and line breaks.
|
||||
7. Maintain the original code structure and only make changes as specified by the user's instructions.
|
||||
8. Ensure that the modified code is syntactically and semantically correct for the given programming language.
|
||||
9. Use consistent indentation and follow language-specific style guidelines.
|
||||
10. If the user's request cannot be translated into code changes, respond only with the word NULL (without quotes or any formatting).
|
||||
11. Do not include any comments or explanations within the code unless specifically requested.
|
||||
12. Assume that any necessary dependencies or libraries are already imported or available.
|
||||
{{PROJECT_CONTEXT}}
|
||||
|
||||
IMPORTANT: Your response must NEVER begin or end with triple backticks, single backticks, or any other formatting characters.
|
||||
## Current File (Editable)
|
||||
{{CURRENT_FILE_CONTEXT}}
|
||||
|
||||
{{EXTERNAL_CONTEXT}}
|
||||
|
||||
## Task
|
||||
Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks.
|
||||
|
||||
## Format
|
||||
Use SEARCH/REPLACE blocks to specify exact code changes:
|
||||
|
||||
Single change in the same file:
|
||||
```language:filepath
|
||||
<<<<<<< SEARCH
|
||||
[exact code to find]
|
||||
=======
|
||||
[replacement code]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
Multiple changes in the same file:
|
||||
```language:filepath
|
||||
<<<<<<< SEARCH
|
||||
[first section]
|
||||
=======
|
||||
[first replacement]
|
||||
>>>>>>> REPLACE
|
||||
|
||||
<<<<<<< SEARCH
|
||||
[second section]
|
||||
=======
|
||||
[second replacement]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
## Requirements
|
||||
• Match indentation and formatting exactly
|
||||
• Include 2-4 lines of context for unique pattern matching
|
||||
• Generate blocks only for the current file path
|
||||
• Use complete code sections without truncation
|
||||
• Ensure search and replacement content differ
|
||||
• Include file path after language identifier using colon
|
||||
• Make search patterns unique within the file
|
||||
|
||||
## Example
|
||||
```java:src/Example.java
|
||||
<<<<<<< SEARCH
|
||||
public int calculate(int x, int y) {
|
||||
return x + y;
|
||||
}
|
||||
=======
|
||||
public int calculate(int x, int y) {
|
||||
if (x < 0 || y < 0) {
|
||||
throw new IllegalArgumentException("Values must be non-negative");
|
||||
}
|
||||
return x + y;
|
||||
}
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
package ee.carlrobert.codegpt.completions
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.ReferencedFile
|
||||
import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.conversations.ConversationService
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.settings.configuration.ChatMode
|
||||
import ee.carlrobert.codegpt.settings.prompts.PersonaPromptDetailsState
|
||||
import ee.carlrobert.codegpt.settings.prompts.PersonasState
|
||||
import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent
|
||||
import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel
|
||||
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
import java.io.File
|
||||
|
||||
class OpenAIRequestFactoryIntegrationTest : IntegrationTest() {
|
||||
|
||||
|
|
@ -297,86 +303,367 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() {
|
|||
.map { it.content }
|
||||
assertThat(systemMessages).isNotEmpty()
|
||||
val systemContent = systemMessages.first()
|
||||
assertThat(systemContent).isEqualTo("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:\n" +
|
||||
"\n" +
|
||||
"Before we proceed with the main instructions, here is the content of relevant files in the project:\n" +
|
||||
"\n" +
|
||||
"<project_path>\n" +
|
||||
"UNDEFINED\n" +
|
||||
"</project_path>\n" +
|
||||
"\n" +
|
||||
"Instructions:\n" +
|
||||
"\n" +
|
||||
"1. Detect the intent behind the user's query:\n" +
|
||||
" - New code suggestion\n" +
|
||||
" - Technical explanation\n" +
|
||||
" - Code refactoring or editing\n" +
|
||||
"\n" +
|
||||
"2. For queries not related to the codebase or for new files, provide a standard code or text block response.\n" +
|
||||
"\n" +
|
||||
"3. For refactoring or editing an existing file, always generate a SEARCH/REPLACE block.\n" +
|
||||
"\n" +
|
||||
"4. For any code generation, refactoring, or editing task:\n" +
|
||||
" a. First, outline an implementation plan describing the steps to address the user's request.\n" +
|
||||
" b. As you generate code or SEARCH/REPLACE blocks, reference the relevant step(s) from your plan, explaining your approach for each change.\n" +
|
||||
" c. For complex tasks, break down the plan and code changes into smaller steps, presenting each with its rationale and code diff together.\n" +
|
||||
" d. If the user's intent is unclear, ask clarifying questions before proceeding.\n" +
|
||||
"\n" +
|
||||
"5. When generating SEARCH/REPLACE blocks:\n" +
|
||||
" a. Ensure each block represents an atomic, non-overlapping change that can be applied independently.\n" +
|
||||
" b. Provide sufficient context in the SEARCH part to uniquely locate the change.\n" +
|
||||
" c. Keep SEARCH blocks concise while including necessary surrounding lines.\n" +
|
||||
"\n" +
|
||||
"Formatting Guidelines:\n" +
|
||||
"\n" +
|
||||
"1. Begin with a brief, impersonal acknowledgment.\n" +
|
||||
"\n" +
|
||||
"2. Use the following format for code blocks:\n" +
|
||||
" ```[language]:[full_file_path]\n" +
|
||||
" [code content]\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
" Example:\n" +
|
||||
" ```java:/path/to/Main.java\n" +
|
||||
" public class Main {\n" +
|
||||
" public static void main(String[] args) {\n" +
|
||||
" System.out.println(\"Hello, World!\");\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
"3. For new files, show the entire file content in a single code fence.\n" +
|
||||
"\n" +
|
||||
"4. For editing existing files, use this SEARCH/REPLACE structure:\n" +
|
||||
" ```[language]:[full_file_path]\n" +
|
||||
" <<<<<<< SEARCH\n" +
|
||||
" [exact lines from the file, including whitespace/comments]\n" +
|
||||
" =======\n" +
|
||||
" [replacement lines]\n" +
|
||||
" >>>>>>> REPLACE\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
" Example:\n" +
|
||||
" ```java:/path/to/Calculator.java\n" +
|
||||
" <<<<<<< SEARCH\n" +
|
||||
" public int add(int a, int b) {\n" +
|
||||
" return a + b;\n" +
|
||||
" }\n" +
|
||||
" =======\n" +
|
||||
" public int add(int a, int b) {\n" +
|
||||
" // Added input validation\n" +
|
||||
" if (a < 0 || b < 0) {\n" +
|
||||
" throw new IllegalArgumentException(\"Negative numbers not allowed\");\n" +
|
||||
" }\n" +
|
||||
" return a + b;\n" +
|
||||
" }\n" +
|
||||
" >>>>>>> REPLACE\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
"5. Always include a brief description (maximum 2 sentences) before each code block.\n" +
|
||||
"\n" +
|
||||
"6. Do not provide an implementation plan for pure explanations or general questions.\n" +
|
||||
"\n" +
|
||||
"7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n")
|
||||
assertThat(systemContent).isEqualTo(
|
||||
"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:\n" +
|
||||
"\n" +
|
||||
"Before we proceed with the main instructions, here is the content of relevant files in the project:\n" +
|
||||
"\n" +
|
||||
"<project_path>\n" +
|
||||
"UNDEFINED\n" +
|
||||
"</project_path>\n" +
|
||||
"\n" +
|
||||
"Instructions:\n" +
|
||||
"\n" +
|
||||
"1. Detect the intent behind the user's query:\n" +
|
||||
" - New code suggestion\n" +
|
||||
" - Technical explanation\n" +
|
||||
" - Code refactoring or editing\n" +
|
||||
"\n" +
|
||||
"2. For queries not related to the codebase or for new files, provide a standard code or text block response.\n" +
|
||||
"\n" +
|
||||
"3. For refactoring or editing an existing file, always generate a SEARCH/REPLACE block.\n" +
|
||||
"\n" +
|
||||
"4. For any code generation, refactoring, or editing task:\n" +
|
||||
" a. First, outline an implementation plan describing the steps to address the user's request.\n" +
|
||||
" b. As you generate code or SEARCH/REPLACE blocks, reference the relevant step(s) from your plan, explaining your approach for each change.\n" +
|
||||
" c. For complex tasks, break down the plan and code changes into smaller steps, presenting each with its rationale and code diff together.\n" +
|
||||
" d. If the user's intent is unclear, ask clarifying questions before proceeding.\n" +
|
||||
"\n" +
|
||||
"5. When generating SEARCH/REPLACE blocks:\n" +
|
||||
" a. Ensure each block represents an atomic, non-overlapping change that can be applied independently.\n" +
|
||||
" b. Provide sufficient context in the SEARCH part to uniquely locate the change.\n" +
|
||||
" c. Keep SEARCH blocks concise while including necessary surrounding lines.\n" +
|
||||
"\n" +
|
||||
"Formatting Guidelines:\n" +
|
||||
"\n" +
|
||||
"1. Begin with a brief, impersonal acknowledgment.\n" +
|
||||
"\n" +
|
||||
"2. Use the following format for code blocks:\n" +
|
||||
" ```[language]:[full_file_path]\n" +
|
||||
" [code content]\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
" Example:\n" +
|
||||
" ```java:/path/to/Main.java\n" +
|
||||
" public class Main {\n" +
|
||||
" public static void main(String[] args) {\n" +
|
||||
" System.out.println(\"Hello, World!\");\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
"3. For new files, show the entire file content in a single code fence.\n" +
|
||||
"\n" +
|
||||
"4. For editing existing files, use this SEARCH/REPLACE structure:\n" +
|
||||
" ```[language]:[full_file_path]\n" +
|
||||
" <<<<<<< SEARCH\n" +
|
||||
" [exact lines from the file, including whitespace/comments]\n" +
|
||||
" =======\n" +
|
||||
" [replacement lines]\n" +
|
||||
" >>>>>>> REPLACE\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
" Example:\n" +
|
||||
" ```java:/path/to/Calculator.java\n" +
|
||||
" <<<<<<< SEARCH\n" +
|
||||
" public int add(int a, int b) {\n" +
|
||||
" return a + b;\n" +
|
||||
" }\n" +
|
||||
" =======\n" +
|
||||
" public int add(int a, int b) {\n" +
|
||||
" // Added input validation\n" +
|
||||
" if (a < 0 || b < 0) {\n" +
|
||||
" throw new IllegalArgumentException(\"Negative numbers not allowed\");\n" +
|
||||
" }\n" +
|
||||
" return a + b;\n" +
|
||||
" }\n" +
|
||||
" >>>>>>> REPLACE\n" +
|
||||
" ```\n" +
|
||||
"\n" +
|
||||
"5. Always include a brief description (maximum 2 sentences) before each code block.\n" +
|
||||
"\n" +
|
||||
"6. Do not provide an implementation plan for pure explanations or general questions.\n" +
|
||||
"\n" +
|
||||
"7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n"
|
||||
)
|
||||
}
|
||||
|
||||
fun testInlineEditSingleRequestNoHistory() {
|
||||
useOpenAIService(OpenAIChatCompletionModel.GPT_4_1.code, FeatureType.INLINE_EDIT)
|
||||
val testFileContent = getResourceContent("/inline/TestClass.java")
|
||||
val tempFile = File.createTempFile("TestClass", ".java")
|
||||
tempFile.writeText(testFileContent)
|
||||
tempFile.deleteOnExit()
|
||||
val parameters = InlineEditCompletionParameters(
|
||||
prompt = "add logging to this method",
|
||||
selectedText = "myTestMethod()",
|
||||
filePath = tempFile.absolutePath,
|
||||
fileExtension = "java",
|
||||
projectBasePath = project.basePath,
|
||||
referencedFiles = null,
|
||||
gitDiff = null,
|
||||
conversation = null,
|
||||
conversationHistory = null,
|
||||
diagnosticsInfo = null
|
||||
)
|
||||
|
||||
val request = OpenAIRequestFactory().createInlineEditRequest(parameters)
|
||||
|
||||
val systemMessage = request.messages[0] as OpenAIChatCompletionStandardMessage
|
||||
assertThat(systemMessage.role).isEqualTo("system")
|
||||
assertThat(systemMessage.content).isEqualTo(
|
||||
"""
|
||||
You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file.
|
||||
|
||||
Project Context:
|
||||
Project root: ${project.basePath}
|
||||
All file paths should be relative to this project root.
|
||||
|
||||
## Current File (Editable)
|
||||
```java:${tempFile.absolutePath}
|
||||
$testFileContent
|
||||
```
|
||||
|
||||
## External Context
|
||||
|
||||
No external context selected.
|
||||
|
||||
## Task
|
||||
Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks.
|
||||
|
||||
## Format
|
||||
Use SEARCH/REPLACE blocks to specify exact code changes:
|
||||
|
||||
Single change in the same file:
|
||||
```language:filepath
|
||||
<<<<<<< SEARCH
|
||||
[exact code to find]
|
||||
=======
|
||||
[replacement code]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
Multiple changes in the same file:
|
||||
```language:filepath
|
||||
<<<<<<< SEARCH
|
||||
[first section]
|
||||
=======
|
||||
[first replacement]
|
||||
>>>>>>> REPLACE
|
||||
|
||||
<<<<<<< SEARCH
|
||||
[second section]
|
||||
=======
|
||||
[second replacement]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
## Requirements
|
||||
• Match indentation and formatting exactly
|
||||
• Include 2-4 lines of context for unique pattern matching
|
||||
• Generate blocks only for the current file path
|
||||
• Use complete code sections without truncation
|
||||
• Ensure search and replacement content differ
|
||||
• Include file path after language identifier using colon
|
||||
• Make search patterns unique within the file
|
||||
|
||||
## Example
|
||||
```java:src/Example.java
|
||||
<<<<<<< SEARCH
|
||||
public int calculate(int x, int y) {
|
||||
return x + y;
|
||||
}
|
||||
=======
|
||||
public int calculate(int x, int y) {
|
||||
if (x < 0 || y < 0) {
|
||||
throw new IllegalArgumentException("Values must be non-negative");
|
||||
}
|
||||
return x + y;
|
||||
}
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
""".trimIndent()
|
||||
)
|
||||
val userMessage = request.messages[1] as OpenAIChatCompletionStandardMessage
|
||||
assertThat(userMessage.role).isEqualTo("user")
|
||||
assertThat(userMessage.content).isEqualTo(
|
||||
"""
|
||||
Selected code:
|
||||
```java
|
||||
myTestMethod()
|
||||
```
|
||||
|
||||
Request: add logging to this method
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
fun testInlineEditFollowUpWithHistory() {
|
||||
useOpenAIService(OpenAIChatCompletionModel.GPT_4_1.code, FeatureType.INLINE_EDIT)
|
||||
val testFileContent = getResourceContent("/inline/TestClass.java")
|
||||
val tempFile = File.createTempFile("TestClass", ".java")
|
||||
tempFile.writeText(testFileContent)
|
||||
tempFile.deleteOnExit()
|
||||
val parameters = InlineEditCompletionParameters(
|
||||
prompt = "TEST_FOLLOW_UP_PROMPT",
|
||||
selectedText = "myTestMethod()",
|
||||
filePath = tempFile.absolutePath,
|
||||
fileExtension = "java",
|
||||
projectBasePath = project.basePath,
|
||||
referencedFiles = mutableListOf(
|
||||
ReferencedFile(
|
||||
"TEST_FILE_NAME_1.java",
|
||||
"/path/to/TEST_FILE_NAME_1.java",
|
||||
"TEST_FILE_CONTENT_1"
|
||||
),
|
||||
ReferencedFile(
|
||||
"TEST_FILE_NAME_2.java",
|
||||
"/path/to/TEST_FILE_NAME_2.java",
|
||||
"TEST_FILE_CONTENT_2"
|
||||
)
|
||||
),
|
||||
gitDiff = "TEST_GIT_DIFF",
|
||||
conversation = Conversation().apply {
|
||||
messages = mutableListOf(
|
||||
Message("PREV_PROMPT").apply {
|
||||
response = "PREV_RESPONSE"
|
||||
}
|
||||
)
|
||||
},
|
||||
conversationHistory = listOf(
|
||||
Conversation().apply {
|
||||
messages = mutableListOf(
|
||||
Message("HISTORY_PROMPT_1").apply {
|
||||
response = "HISTORY_RESPONSE_1"
|
||||
}
|
||||
)
|
||||
},
|
||||
Conversation().apply {
|
||||
messages = mutableListOf(
|
||||
Message("HISTORY_PROMPT_2").apply {
|
||||
response = "HISTORY_RESPONSE_2"
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
diagnosticsInfo = null
|
||||
)
|
||||
|
||||
val request = OpenAIRequestFactory().createInlineEditRequest(parameters)
|
||||
|
||||
val systemMessage = request.messages[0] as OpenAIChatCompletionStandardMessage
|
||||
assertThat(systemMessage.role).isEqualTo("system")
|
||||
assertThat(systemMessage.content).isEqualTo(
|
||||
"""
|
||||
You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file.
|
||||
|
||||
Project Context:
|
||||
Project root: ${project.basePath}
|
||||
All file paths should be relative to this project root.
|
||||
|
||||
## Current File (Editable)
|
||||
```java:${tempFile.absolutePath}
|
||||
$testFileContent
|
||||
```
|
||||
|
||||
## External Context
|
||||
|
||||
### Referenced Files
|
||||
|
||||
```java:/path/to/TEST_FILE_NAME_1.java
|
||||
TEST_FILE_CONTENT_1
|
||||
```
|
||||
|
||||
```java:/path/to/TEST_FILE_NAME_2.java
|
||||
TEST_FILE_CONTENT_2
|
||||
```
|
||||
|
||||
### Git Diff
|
||||
|
||||
```diff
|
||||
TEST_GIT_DIFF
|
||||
```
|
||||
|
||||
### Conversation History
|
||||
|
||||
**User:** HISTORY_PROMPT_1
|
||||
**Assistant:** HISTORY_RESPONSE_1
|
||||
**User:** HISTORY_PROMPT_2
|
||||
**Assistant:** HISTORY_RESPONSE_2
|
||||
|
||||
## Task
|
||||
Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks.
|
||||
|
||||
## Format
|
||||
Use SEARCH/REPLACE blocks to specify exact code changes:
|
||||
|
||||
Single change in the same file:
|
||||
```language:filepath
|
||||
<<<<<<< SEARCH
|
||||
[exact code to find]
|
||||
=======
|
||||
[replacement code]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
Multiple changes in the same file:
|
||||
```language:filepath
|
||||
<<<<<<< SEARCH
|
||||
[first section]
|
||||
=======
|
||||
[first replacement]
|
||||
>>>>>>> REPLACE
|
||||
|
||||
<<<<<<< SEARCH
|
||||
[second section]
|
||||
=======
|
||||
[second replacement]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
## Requirements
|
||||
• Match indentation and formatting exactly
|
||||
• Include 2-4 lines of context for unique pattern matching
|
||||
• Generate blocks only for the current file path
|
||||
• Use complete code sections without truncation
|
||||
• Ensure search and replacement content differ
|
||||
• Include file path after language identifier using colon
|
||||
• Make search patterns unique within the file
|
||||
|
||||
## Example
|
||||
```java:src/Example.java
|
||||
<<<<<<< SEARCH
|
||||
public int calculate(int x, int y) {
|
||||
return x + y;
|
||||
}
|
||||
=======
|
||||
public int calculate(int x, int y) {
|
||||
if (x < 0 || y < 0) {
|
||||
throw new IllegalArgumentException("Values must be non-negative");
|
||||
}
|
||||
return x + y;
|
||||
}
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
""".trimIndent()
|
||||
)
|
||||
val prevUserMessage = request.messages[1] as OpenAIChatCompletionStandardMessage
|
||||
assertThat(prevUserMessage.role).isEqualTo("user")
|
||||
assertThat(prevUserMessage.content).isEqualTo("PREV_PROMPT")
|
||||
val prevResponse = request.messages[2] as OpenAIChatCompletionStandardMessage
|
||||
assertThat(prevResponse.role).isEqualTo("assistant")
|
||||
assertThat(prevResponse.content).isEqualTo("PREV_RESPONSE")
|
||||
val followUpMessage = request.messages[3] as OpenAIChatCompletionStandardMessage
|
||||
assertThat(followUpMessage.role).isEqualTo("user")
|
||||
assertThat(followUpMessage.content).isEqualTo(
|
||||
"""
|
||||
Selected code:
|
||||
```java
|
||||
myTestMethod()
|
||||
```
|
||||
|
||||
Request: TEST_FOLLOW_UP_PROMPT
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import com.intellij.util.messages.MessageBusConnection
|
|||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.llm.client.codegpt.PricingPlan
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
|
@ -42,7 +41,7 @@ class ModelSettingsTest : IntegrationTest() {
|
|||
lastNotification.set(NotificationData(FeatureType.COMMIT_MESSAGE, newModel, serviceType, "commitMessage"))
|
||||
}
|
||||
override fun editCodeModelChanged(newModel: String, serviceType: ServiceType) {
|
||||
lastNotification.set(NotificationData(FeatureType.EDIT_CODE, newModel, serviceType, "editCode"))
|
||||
lastNotification.set(NotificationData(FeatureType.INLINE_EDIT, newModel, serviceType, "editCode"))
|
||||
}
|
||||
override fun nextEditModelChanged(newModel: String, serviceType: ServiceType) {
|
||||
lastNotification.set(NotificationData(FeatureType.NEXT_EDIT, newModel, serviceType, "nextEdit"))
|
||||
|
|
@ -121,10 +120,10 @@ class ModelSettingsTest : IntegrationTest() {
|
|||
fun `test setModelWithProvider with edit code triggers edit code notification`() {
|
||||
lastNotification.set(null)
|
||||
|
||||
modelSettings.setModelWithProvider(FeatureType.EDIT_CODE, "claude-4-sonnet", ServiceType.PROXYAI)
|
||||
modelSettings.setModelWithProvider(FeatureType.INLINE_EDIT, "claude-4-sonnet", ServiceType.PROXYAI)
|
||||
|
||||
val notification = lastNotification.get()
|
||||
assertThat(notification!!.featureType).isEqualTo(FeatureType.EDIT_CODE)
|
||||
assertThat(notification!!.featureType).isEqualTo(FeatureType.INLINE_EDIT)
|
||||
assertThat(notification.model).isEqualTo("claude-4-sonnet")
|
||||
assertThat(notification.serviceType).isEqualTo(ServiceType.PROXYAI)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,19 +27,16 @@ interface ShortcutsTestMixin {
|
|||
}
|
||||
}
|
||||
|
||||
fun useOpenAIService(chatModel: String? = "gpt-4o", role: FeatureType = FeatureType.CHAT) {
|
||||
fun useOpenAIService(chatModel: String? = "gpt-4o", featureType: FeatureType = FeatureType.CHAT) {
|
||||
setCredential(OpenaiApiKey, "TEST_API_KEY")
|
||||
val modelSettings = service<ModelSettings>()
|
||||
|
||||
when (role) {
|
||||
FeatureType.CHAT -> {
|
||||
modelSettings.setModel(FeatureType.CHAT, chatModel ?: "gpt-4o", ServiceType.OPENAI)
|
||||
}
|
||||
when (featureType) {
|
||||
FeatureType.CODE_COMPLETION -> {
|
||||
modelSettings.setModel(FeatureType.CODE_COMPLETION, "gpt-3.5-turbo-instruct", ServiceType.OPENAI)
|
||||
}
|
||||
else -> {
|
||||
modelSettings.setModel(FeatureType.CHAT, chatModel ?: "gpt-4o", ServiceType.OPENAI)
|
||||
modelSettings.setModel(featureType, chatModel ?: "gpt-4o", ServiceType.OPENAI)
|
||||
modelSettings.setModel(FeatureType.CODE_COMPLETION, "gpt-3.5-turbo-instruct", ServiceType.OPENAI)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
src/test/resources/inline/TestClass.java
Normal file
6
src/test/resources/inline/TestClass.java
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
public class TestClass {
|
||||
|
||||
public static void myTestMethod() {
|
||||
System.out.println("Hello world");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue