feat: dnd files and folders from project window (closes #1124)

This commit is contained in:
Carl-Robert Linnupuu 2025-09-12 17:01:24 +01:00
parent 807d039797
commit c42ebeb691
4 changed files with 170 additions and 7 deletions

View file

@ -520,4 +520,4 @@ public class ChatToolWindowTabPanel implements Disposable {
rootPanel.add(createUserPromptPanel(), BorderLayout.SOUTH);
return rootPanel;
}
}
}

View file

@ -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<VirtualFile>) -> Unit) {
install(component, component, onFilesDropped)
}
fun install(
component: JComponent,
highlightTarget: JComponent,
onFilesDropped: (List<VirtualFile>) -> 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<DataFlavor>): 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<VirtualFile> {
val out = mutableListOf<VirtualFile>()
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<File> {
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<VirtualFile>, 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()
}
}

View file

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

View file

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