feat: copy-paste placeholder for prompt text field

This commit is contained in:
Carl-Robert Linnupuu 2025-10-02 13:15:05 +01:00
parent 14f16465d3
commit 08804c4c6e
4 changed files with 355 additions and 12 deletions

View file

@ -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<VirtualFile>) -> 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<LookupActionItem>? = 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<Int, Int> {
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<PastePlaceholder> = 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<PastePlaceholder> {
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<PastePlaceholder>()
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<PromptTextField> =
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
}
}
}
}

View file

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

View file

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

View file

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