From 08804c4c6e3b317bea19b340bfaffcfe224a6c86 Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Thu, 2 Oct 2025 13:15:05 +0100 Subject: [PATCH] feat: copy-paste placeholder for prompt text field --- .../codegpt/ui/textarea/PromptTextField.kt | 333 +++++++++++++++++- .../ui/textarea/PromptTextFieldConstants.kt | 3 +- .../PromptTextFieldEventDispatcher.kt | 29 ++ .../codegpt/ui/textarea/UserInputPanel.kt | 2 +- 4 files changed, 355 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt index 4fb1731a..2137bec1 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -6,34 +6,54 @@ import com.intellij.codeInsight.lookup.LookupListener import com.intellij.codeInsight.lookup.impl.LookupImpl import com.intellij.ide.IdeEventQueue import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runUndoTransparentWriteAction import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.Caret import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorActionHandler +import com.intellij.openapi.editor.actionSystem.EditorActionManager import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.EffectType +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.FileTypes +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.project.Project -import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.EditorTextField +import com.intellij.ui.JBColor 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.settings.service.FeatureType +import ee.carlrobert.codegpt.ui.dnd.FileDragAndDrop import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem -import ee.carlrobert.codegpt.ui.dnd.FileDragAndDrop import kotlinx.coroutines.* +import java.awt.Cursor import java.awt.Dimension +import java.awt.datatransfer.DataFlavor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter import java.util.* import javax.swing.JComponent +import javax.swing.TransferHandler class PromptTextField( private val project: Project, @@ -43,17 +63,16 @@ class PromptTextField( private val onLookupAdded: (LookupActionItem) -> Unit, private val onSubmit: (String) -> Unit, private val onFilesDropped: (List) -> Unit = {}, - private val featureType: FeatureType? = null + featureType: FeatureType? = null ) : EditorTextField(project, FileTypes.PLAIN_TEXT), Disposable { - companion object { - private val logger = thisLogger() - } - private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val lookupManager = PromptTextFieldLookupManager(project, onLookupAdded) private val searchManager = SearchManager(project, tagManager, featureType) + private var mouseClickListener: MouseAdapter? = null + private var mouseMotionListener: MouseMotionAdapter? = null + private var showSuggestionsJob: Job? = null private var searchState = SearchState() private var lastSearchResults: List? = null @@ -64,9 +83,11 @@ class PromptTextField( init { isOneLineMode = false IS_PROMPT_TEXT_FIELD_DOCUMENT.set(document, true) + document.putUserData(PROMPT_FIELD_KEY, this) setPlaceholder(CodeGPTBundle.get("toolwindow.chat.textArea.emptyText")) - + putClientProperty(UIUtil.HIDE_EDITOR_FROM_DATA_CONTEXT_PROPERTY, true) + installPasteHandler() } override fun onEditorAdded(editor: Editor) { @@ -77,18 +98,77 @@ class PromptTextField( return@PromptTextFieldEventDispatcher } - onSubmit(text) + onSubmit(getExpandedText()) event.consume() }, this ) - val highlightTarget = (this.parent as? JComponent) ?: this + val highlightTarget = (parent as? JComponent) ?: this FileDragAndDrop.install(editor.contentComponent, highlightTarget) { onFilesDropped(it) } + + val contentComponent = editor.contentComponent + val previousHandler = contentComponent.transferHandler + contentComponent.transferHandler = object : TransferHandler() { + override fun canImport(support: TransferSupport): Boolean { + return support.isDataFlavorSupported(DataFlavor.stringFlavor) || + (previousHandler?.canImport(support) == true) + } + + override fun importData(support: TransferSupport): Boolean { + if (!support.isDrop && support.isDataFlavorSupported(DataFlavor.stringFlavor)) { + val pasted = try { + support.transferable.getTransferData(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + if (!pasted.isNullOrEmpty()) { + insertPlaceholderFor(pasted) + return true + } + return true + } + return previousHandler?.importData(support) == true + } + } + + val clickListener = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val editor = this@PromptTextField.editor as? EditorEx ?: return + val offset = editor.logicalPositionToOffset(editor.xyToLogicalPosition(e.point)) + val placeholder = findPlaceholderAtOffset(offset) ?: return + val start = placeholder.highlighter.startOffset + val end = placeholder.highlighter.endOffset + runUndoTransparentWriteAction { + editor.document.replaceString(start, end, placeholder.content) + editor.caretModel.moveToOffset(start + placeholder.content.length) + } + editor.markupModel.removeHighlighter(placeholder.highlighter) + placeholders.remove(placeholder) + } + } + mouseClickListener = clickListener + editor.contentComponent.addMouseListener(clickListener) + + val motionListener = object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val editor = this@PromptTextField.editor as? EditorEx ?: return + val offset = editor.logicalPositionToOffset(editor.xyToLogicalPosition(e.point)) + val inside = findPlaceholderAtOffset(offset) != null + val component = editor.contentComponent + component.cursor = + if (inside) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + else Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR) + component.toolTipText = if (inside) "Click to edit" else null + } + } + mouseMotionListener = motionListener + editor.contentComponent.addMouseMotionListener(motionListener) } fun clear() { runInEdt { text = "" + clearPlaceholders() } } @@ -223,6 +303,158 @@ class PromptTextField( override fun dispose() { showSuggestionsJob?.cancel() lastSearchResults = null + clearPlaceholders() + val ed = this.editor + mouseClickListener?.let { l -> ed?.contentComponent?.removeMouseListener(l) } + mouseMotionListener?.let { l -> ed?.contentComponent?.removeMouseMotionListener(l) } + } + + fun insertPlaceholderFor(pastedText: String) { + val editor = editor as? EditorEx ?: return + if (pastedText.isEmpty()) return + + if (pastedText.length <= PromptTextFieldConstants.PASTE_PLACEHOLDER_MIN_LENGTH) { + runUndoTransparentWriteAction { replaceSelectionOrInsert(editor, pastedText) } + return + } + + val placeholderLabel = " Pasted Content ${pastedText.length} chars " + runUndoTransparentWriteAction { + val (start, end) = replaceSelectionOrInsert(editor, placeholderLabel) + addPastePlaceholder(editor, start, end, placeholderLabel, pastedText) + } + } + + private fun replaceSelectionOrInsert(editor: EditorEx, text: String): Pair { + val document = editor.document + val caret = editor.caretModel + val selectionModel = editor.selectionModel + val start: Int + if (selectionModel.hasSelection()) { + val selectionStart = selectionModel.selectionStart + val selectionEnd = selectionModel.selectionEnd + document.replaceString(selectionStart, selectionEnd, text) + selectionModel.removeSelection() + start = selectionStart + } else { + start = caret.offset + document.insertString(start, text) + } + val end = start + text.length + caret.moveToOffset(end) + return start to end + } + + private fun addPastePlaceholder( + editor: EditorEx, + start: Int, + end: Int, + label: String, + content: String + ) { + val attrs = TextAttributes().apply { + backgroundColor = JBColor(0xF2F4F7, 0x2B2D30) + effectType = EffectType.ROUNDED_BOX + effectColor = JBColor(0xC4C9D0, 0x44484F) + } + val highlighter = editor.markupModel.addRangeHighlighter( + start, + end, + HighlighterLayer.ADDITIONAL_SYNTAX, + attrs, + HighlighterTargetArea.EXACT_RANGE + ) + highlighter.isGreedyToLeft = false + highlighter.isGreedyToRight = false + placeholders.add(PastePlaceholder(highlighter, label, content)) + } + + private data class PastePlaceholder( + val highlighter: RangeHighlighter, + val label: String, + var content: String + ) + + private val placeholders: MutableList = mutableListOf() + + fun getExpandedText(): String { + val text = document.text + if (placeholders.isEmpty()) return text + val validPlaceholders = + placeholders.filter { it.highlighter.isValid }.sortedBy { it.highlighter.startOffset } + if (validPlaceholders.isEmpty()) return text + val result = StringBuilder() + var cursor = 0 + for (placeholder in validPlaceholders) { + val start = placeholder.highlighter.startOffset + val end = placeholder.highlighter.endOffset + if (start < cursor || start > text.length || end > text.length) continue + if (cursor < start) result.append(text, cursor, start) + val span = text.substring(start, end) + if (span == placeholder.label) result.append(placeholder.content) else result.append( + span + ) + cursor = end + } + if (cursor < text.length) result.append(text.substring(cursor)) + return result.toString() + } + + private fun findPlaceholderAtOffset(offset: Int): PastePlaceholder? { + return placeholders.firstOrNull { ph -> + ph.highlighter.isValid && offset >= ph.highlighter.startOffset && offset < ph.highlighter.endOffset + } + } + + private fun findPlaceholdersIntersecting(start: Int, end: Int): List { + return placeholders.filter { ph -> + ph.highlighter.isValid && ph.highlighter.startOffset < end && ph.highlighter.endOffset > start + } + } + + private fun clearPlaceholders() { + val ed = this.editor as? EditorEx ?: return + placeholders.forEach { ph -> ed.markupModel.removeHighlighter(ph.highlighter) } + placeholders.clear() + } + + fun handlePlaceholderDelete(isBackspace: Boolean): Boolean { + val editor = editor as? EditorEx ?: return false + val document = editor.document + val caret = editor.caretModel + val selectionModel = editor.selectionModel + + if (selectionModel.hasSelection()) { + val selStart = selectionModel.selectionStart + val selEnd = selectionModel.selectionEnd + val intersecting = findPlaceholdersIntersecting(selStart, selEnd) + if (intersecting.isNotEmpty()) { + val newStart = minOf(selStart, intersecting.minOf { it.highlighter.startOffset }) + val newEnd = maxOf(selEnd, intersecting.maxOf { it.highlighter.endOffset }) + runUndoTransparentWriteAction { + document.deleteString(newStart, newEnd) + caret.moveToOffset(newStart) + } + intersecting.forEach { ph -> editor.markupModel.removeHighlighter(ph.highlighter) } + placeholders.removeAll(intersecting.toSet()) + selectionModel.removeSelection() + return true + } + return false + } + + val offset = caret.offset + val target = if (isBackspace) (if (offset > 0) offset - 1 else offset) else offset + val placeholder = findPlaceholderAtOffset(target) ?: return false + val start = placeholder.highlighter.startOffset + val end = placeholder.highlighter.endOffset + runUndoTransparentWriteAction { + document.deleteString(start, end) + caret.moveToOffset(start) + } + editor.markupModel.removeHighlighter(placeholder.highlighter) + placeholders.remove(placeholder) + return true } private fun setupDocumentListener(editor: EditorEx) { @@ -236,6 +468,7 @@ class PromptTextField( } private fun handleDocumentChange(event: DocumentEvent) { + prunePlaceholders(event) val text = event.document.text val caretOffset = event.offset + event.newLength @@ -245,6 +478,42 @@ class PromptTextField( } } + private fun prunePlaceholders(event: DocumentEvent) { + if (placeholders.isEmpty()) return + + val editor = editor as? EditorEx ?: return + val document = event.document + val textLength = document.textLength + val placeholdersToRemove = mutableListOf() + for (placeholder in placeholders) { + val highlighter = placeholder.highlighter + if (!highlighter.isValid) { + placeholdersToRemove.add(placeholder) + continue + } + val start = highlighter.startOffset + val end = highlighter.endOffset + if (start < 0 || end > textLength || start >= end) { + placeholdersToRemove.add(placeholder) + continue + } + val span = try { + document.charsSequence.subSequence(start, end).toString() + } catch (_: Exception) { + null + } + if (span == null || span != placeholder.label) { + placeholdersToRemove.add(placeholder) + } + } + if (placeholdersToRemove.isNotEmpty()) { + placeholdersToRemove.forEach { placeholder -> + editor.markupModel.removeHighlighter(placeholder.highlighter) + } + placeholders.removeAll(placeholdersToRemove.toSet()) + } + } + private fun isAtSymbolTyped(event: DocumentEvent): Boolean { return PromptTextFieldConstants.AT_SYMBOL == event.newFragment.toString() } @@ -385,4 +654,48 @@ class PromptTextField( return toolWindow.component.visibleRect?.height ?: PromptTextFieldConstants.DEFAULT_TOOL_WINDOW_HEIGHT } + + companion object { + private val logger = thisLogger() + private val PROMPT_FIELD_KEY: Key = + Key.create("codegpt.promptTextField.instance") + + private var pasteHandlerInstalled = false + private var originalPasteHandler: EditorActionHandler? = null + + private fun installPasteHandler() { + if (pasteHandlerInstalled) return + synchronized(PromptTextField::class.java) { + if (pasteHandlerInstalled) return + val manager = EditorActionManager.getInstance() + val existing = manager.getActionHandler(IdeActions.ACTION_EDITOR_PASTE) + originalPasteHandler = existing + manager.setActionHandler( + IdeActions.ACTION_EDITOR_PASTE, + object : EditorActionHandler() { + override fun doExecute( + editor: Editor, + caret: Caret?, + dataContext: DataContext + ) { + val field = editor.document.getUserData(PROMPT_FIELD_KEY) + if (field != null) { + val pasted = try { + CopyPasteManager.getInstance() + .getContents(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + if (!pasted.isNullOrEmpty()) { + field.insertPlaceholderFor(pasted) + return + } + } + originalPasteHandler?.execute(editor, caret, dataContext) + } + }) + pasteHandlerInstalled = true + } + } + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt index 049c36c3..c79eb40b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt @@ -8,6 +8,7 @@ object PromptTextFieldConstants { const val BORDER_PADDING = 4 const val BORDER_SIDE_PADDING = 8 const val HEIGHT_PADDING = 8 + const val PASTE_PLACEHOLDER_MIN_LENGTH = 250 val DEFAULT_GROUP_NAMES = listOf( "files", "file", "f", @@ -29,4 +30,4 @@ object PromptTextFieldConstants { const val LIGHT_THEME_COLOR = 0x00627A const val DARK_THEME_COLOR = 0xCC7832 -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt index e9245968..fb9bc605 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt @@ -12,6 +12,8 @@ import java.awt.event.InputEvent import java.awt.event.KeyEvent import java.awt.event.MouseEvent import java.util.* +import com.intellij.openapi.ide.CopyPasteManager +import java.awt.datatransfer.DataFlavor class PromptTextFieldEventDispatcher( private val dispatcherId: UUID, @@ -32,6 +34,13 @@ class PromptTextFieldEventDispatcher( KeyEvent.VK_DELETE -> handleDelete(e) KeyEvent.VK_A -> if (e.isControlDown || e.isMetaDown) handleSelectAll(e) + KeyEvent.VK_V -> { + if (e.isControlDown || e.isMetaDown) { + if (handlePaste(e)) { + return true + } + } + } KeyEvent.VK_ENTER -> { if (e.isShiftDown) { handleShiftEnter(e) @@ -94,6 +103,10 @@ class PromptTextFieldEventDispatcher( val parent = findParent() if (parent is PromptTextField) { parent.editor?.let { editor -> + if (parent.handlePlaceholderDelete(isBackspace = false)) { + e.consume() + return + } runUndoTransparentWriteAction { val document = editor.document val caretModel = editor.caretModel @@ -116,10 +129,26 @@ class PromptTextFieldEventDispatcher( } } + private fun handlePaste(e: KeyEvent): Boolean { + val parent = findParent() + if (parent is PromptTextField) { + val clipText: String? = try { CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor) as? String } catch (_: Exception) { null } + if (clipText.isNullOrEmpty()) return false + parent.insertPlaceholderFor(clipText) + e.consume() + return true + } + return false + } + private fun handleBackspace(e: KeyEvent): Boolean { val parent = findParent() if (parent is PromptTextField) { parent.editor?.let { editor -> + if (parent.handlePlaceholderDelete(isBackspace = true)) { + e.consume() + return true + } val selectionModel = editor.selectionModel if (selectionModel.hasSelection()) { runUndoTransparentWriteAction { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt index 15b298c0..f7fff641 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -157,7 +157,7 @@ class UserInputPanel @JvmOverloads constructor( IconUtil.scale(Icons.Send, null, 0.85f) ) { override fun actionPerformed(e: AnActionEvent) { - handleSubmit(promptTextField.text) + handleSubmit(promptTextField.getExpandedText()) } }, "SUBMIT"