From c42ebeb691a554f0ae711cbea137115d9e59e634 Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Fri, 12 Sep 2025 17:01:24 +0100 Subject: [PATCH] feat: dnd files and folders from project window (closes #1124) --- .../chat/ChatToolWindowTabPanel.java | 2 +- .../codegpt/ui/dnd/FileDragAndDrop.kt | 136 ++++++++++++++++++ .../codegpt/ui/textarea/PromptTextField.kt | 7 +- .../codegpt/ui/textarea/UserInputPanel.kt | 32 ++++- 4 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/dnd/FileDragAndDrop.kt diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java index d7fe89e0..0438e9f4 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -520,4 +520,4 @@ public class ChatToolWindowTabPanel implements Disposable { rootPanel.add(createUserPromptPanel(), BorderLayout.SOUTH); return rootPanel; } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/dnd/FileDragAndDrop.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/dnd/FileDragAndDrop.kt new file mode 100644 index 00000000..2805fb5a --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/dnd/FileDragAndDrop.kt @@ -0,0 +1,136 @@ +package ee.carlrobert.codegpt.ui.dnd + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.ui.textarea.UserInputPanel +import java.awt.GraphicsEnvironment +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.* +import java.io.File +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import javax.swing.JComponent + +object FileDragAndDrop { + fun install(component: JComponent, onFilesDropped: (List) -> Unit) { + install(component, component, onFilesDropped) + } + + fun install( + component: JComponent, + highlightTarget: JComponent, + onFilesDropped: (List) -> Unit + ) { + val appHeadless = try { + ApplicationManager.getApplication()?.isHeadlessEnvironment == true + } catch (_: Throwable) { + false + } + if (GraphicsEnvironment.isHeadless() || appHeadless) return + DropTarget(component, DnDConstants.ACTION_COPY, object : DropTargetAdapter() { + override fun dragEnter(dragEvent: DropTargetDragEvent) { + if (canImport(dragEvent.currentDataFlavors)) { + dragEvent.acceptDrag(DnDConstants.ACTION_COPY) + setHighlight(highlightTarget, true) + } else dragEvent.rejectDrag() + } + + override fun drop(dropEvent: DropTargetDropEvent) { + val files = extractVirtualFiles(dropEvent.transferable) + if (files.isNotEmpty()) { + dropEvent.acceptDrop(DnDConstants.ACTION_COPY) + onFilesDropped(files) + dropEvent.dropComplete(true) + } else dropEvent.rejectDrop() + setHighlight(highlightTarget, false) + } + + override fun dragExit(dte: DropTargetEvent) { + setHighlight(highlightTarget, false) + } + }, true) + } + + private fun canImport(flavors: Array): Boolean { + return flavors.any { + it == DataFlavor.javaFileListFlavor + || it == DataFlavor.stringFlavor + || isUriListFlavor(it) + } + } + + private fun isUriListFlavor(flavor: DataFlavor): Boolean { + return flavor.primaryType.equals("text", true) && flavor.subType.equals("uri-list", true) + } + + private fun extractVirtualFiles(transferable: java.awt.datatransfer.Transferable): List { + val out = mutableListOf() + try { + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + val list = transferable.getTransferData(DataFlavor.javaFileListFlavor) as? List<*> + list?.mapNotNull { it as? File }?.forEach { addIfExists(out, it) } + } + } catch (_: Exception) { + } + try { + if (transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) { + val s = transferable.getTransferData(DataFlavor.stringFlavor) as? String + if (!s.isNullOrBlank()) parseUriList(s).forEach { addIfExists(out, it) } + } + } catch (_: Exception) { + } + return out.distinct() + } + + private fun parseUriList(data: String): List { + return data.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull { + try { + if (it.startsWith("file:")) File( + URLDecoder.decode( + URI.create(it).path, + StandardCharsets.UTF_8 + ) + ) else File(it) + } catch (_: Exception) { + null + } + } + .toList() + } + + private fun addIfExists(out: MutableList, file: File) { + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)?.let { out += it } + } + + private fun setHighlight(component: JComponent, enabled: Boolean) { + if (component is UserInputPanel) { + component.setDragActive(enabled) + return + } + val key = "codegpt.dnd.prev.border" + if (enabled) { + if (component.getClientProperty(key) == null) component.putClientProperty( + key, + component.border + ) + val focusColor = JBUI.CurrentTheme.Focus.defaultButtonColor() + val overlay = JBUI.Borders.customLine(focusColor, 1) + val base = component.getClientProperty(key) as? javax.swing.border.Border + component.border = JBUI.Borders.merge(base, overlay, true) + } else { + val prev = component.getClientProperty(key) as? javax.swing.border.Border + if (prev != null) { + component.border = prev + component.putClientProperty(key, null) + } + } + component.revalidate() + component.repaint() + } +} 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 37d0e693..c1657149 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -17,6 +17,7 @@ import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileTypes.FileTypes import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.EditorTextField import com.intellij.util.ui.JBUI import ee.carlrobert.codegpt.CodeGPTBundle @@ -25,6 +26,7 @@ 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.Dimension import java.util.* @@ -36,6 +38,7 @@ class PromptTextField( private val onBackSpace: () -> Unit, private val onLookupAdded: (LookupActionItem) -> Unit, private val onSubmit: (String) -> Unit, + private val onFilesDropped: (List) -> Unit = {}, ) : EditorTextField(project, FileTypes.PLAIN_TEXT), Disposable { companion object { @@ -72,6 +75,8 @@ class PromptTextField( }, this ) + val highlightTarget = (this.parent as? javax.swing.JComponent) ?: this + FileDragAndDrop.install(editor.contentComponent as javax.swing.JComponent, highlightTarget) { onFilesDropped(it) } } fun clear() { @@ -361,4 +366,4 @@ class PromptTextField( .getToolWindow("ProxyAI")?.component?.visibleRect?.height ?: PromptTextFieldConstants.DEFAULT_TOOL_WINDOW_HEIGHT } -} \ No newline at end of file +} 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 8c8019ce..85f7fd85 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -21,7 +21,7 @@ import com.intellij.util.IconUtil import com.intellij.util.ui.JBUI import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.Icons -import ee.carlrobert.codegpt.conversations.Conversation +import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.settings.configuration.ChatMode import ee.carlrobert.codegpt.settings.models.ModelRegistry import ee.carlrobert.codegpt.settings.service.FeatureType @@ -30,6 +30,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.dnd.FileDragAndDrop import ee.carlrobert.codegpt.ui.textarea.header.UserInputHeaderPanel import ee.carlrobert.codegpt.ui.textarea.header.tag.* import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem @@ -64,7 +65,11 @@ class UserInputPanel( ::updateUserTokens, ::handleBackSpace, ::handleLookupAdded, - ::handleSubmit + ::handleSubmit, + onFilesDropped = { files -> + includeFiles(files.toMutableList()) + totalTokensPanel.updateReferencedFilesTokens(files.map { ReferencedFile.from(it).fileContent() }) + } ) private val userInputHeaderPanel = UserInputHeaderPanel( @@ -112,6 +117,10 @@ class UserInputPanel( setupDisposables(parentDisposable) setupLayout() addSelectedEditorContent() + FileDragAndDrop.install(this) { files -> + includeFiles(files.toMutableList()) + totalTokensPanel.updateReferencedFilesTokens(files.map { ReferencedFile.from(it).fileContent() }) + } } private fun setupDisposables(parentDisposable: Disposable) { @@ -198,7 +207,13 @@ class UserInputPanel( } fun includeFiles(referencedFiles: MutableList) { - referencedFiles.forEach { userInputHeaderPanel.addTag(FileTagDetails(it)) } + referencedFiles.forEach { vf -> + if (vf.isDirectory) { + userInputHeaderPanel.addTag(FolderTagDetails(vf)) + } else { + userInputHeaderPanel.addTag(FileTagDetails(vf)) + } + } } override fun requestFocus() { @@ -252,12 +267,19 @@ class UserInputPanel( private fun drawRoundedBorder(g2: Graphics2D) { g2.color = JBUI.CurrentTheme.Focus.defaultButtonColor() - if (promptTextField.isFocusOwner) { + if (promptTextField.isFocusOwner || dragActive) { g2.stroke = BasicStroke(1.5F) } g2.drawRoundRect(0, 0, width - 1, height - 1, CORNER_RADIUS, CORNER_RADIUS) } + private var dragActive: Boolean = false + + fun setDragActive(active: Boolean) { + dragActive = active + repaint() + } + override fun getInsets(): Insets = JBUI.insets(4) private fun handleSubmit(text: String) { @@ -350,4 +372,4 @@ class UserInputPanel( ModelRegistry.CLAUDE_4_SONNET_THINKING ) } -} \ No newline at end of file +}