Skip to content

Commit a108689

Browse files
committed
feat: add drag-and-drop file support to embedded TUI
1 parent dadb45c commit a108689

3 files changed

Lines changed: 71 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## [Unreleased]
44

5+
<p><strong>Added</strong></p>
6+
<ul>
7+
<li>Support dragging and dropping files into the embedded TUI.</li>
8+
</ul>
9+
510
## [2.0.0] - 2026-06-17
611

712
<p><strong>Breaking Changes</strong></p>

src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner
1818
import org.jetbrains.plugins.terminal.ShellStartupOptions
1919
import org.jetbrains.plugins.terminal.ShellTerminalWidget
2020
import java.awt.BorderLayout
21+
import java.awt.datatransfer.DataFlavor
22+
import java.awt.dnd.DnDConstants
23+
import java.awt.dnd.DropTarget
24+
import java.awt.dnd.DropTargetAdapter
25+
import java.awt.dnd.DropTargetDropEvent
26+
import java.io.File
2127
import java.lang.reflect.Proxy
2228
import javax.swing.JPanel
2329

@@ -92,14 +98,17 @@ class ClassicTuiPanel(
9298
Disposer.register(this, widget)
9399
terminalPanel =
94100
ShellTerminalWidget.asShellJediTermWidget(widget)?.terminalPanel
95-
?.also { installEmbeddedTerminalDataProvider(project, it) }
101+
?.also { panel ->
102+
installEmbeddedTerminalDataProvider(project, panel)
103+
installFileDropTarget(panel)
104+
}
96105

97106
// When the shell process exits, clean up and notify the owner.
98107
widget.addTerminationCallback({
99108
ApplicationManager.getApplication().invokeLater {
100109
logger.debug("Classic terminal process terminated")
101110
if (terminalWidget === widget) {
102-
uninstallEmbeddedTerminalDataProvider()
111+
uninstallEmbeddedTerminalIntegrations()
103112
terminalWidget = null
104113
remove(widget.component)
105114
revalidate()
@@ -133,7 +142,7 @@ class ClassicTuiPanel(
133142

134143
private fun tearDown() {
135144
val widget = terminalWidget ?: return
136-
uninstallEmbeddedTerminalDataProvider()
145+
uninstallEmbeddedTerminalIntegrations()
137146
terminalWidget = null
138147
remove(widget.component)
139148
revalidate()
@@ -148,11 +157,55 @@ class ClassicTuiPanel(
148157

149158
override fun dispose() = tearDown()
150159

151-
private fun uninstallEmbeddedTerminalDataProvider() {
152-
terminalPanel?.let(::uninstallTerminalToolWindowOverride)
160+
private fun uninstallEmbeddedTerminalIntegrations() {
161+
terminalPanel?.let { panel ->
162+
uninstallEmbeddedTerminalDataProvider(panel)
163+
panel.dropTarget = null
164+
}
153165
terminalPanel = null
154166
}
155167

168+
private fun installFileDropTarget(panel: JBTerminalPanel) {
169+
panel.dropTarget = DropTarget(panel, DnDConstants.ACTION_COPY, object : DropTargetAdapter() {
170+
override fun drop(event: DropTargetDropEvent) {
171+
if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
172+
event.rejectDrop()
173+
return
174+
}
175+
176+
event.acceptDrop(DnDConstants.ACTION_COPY)
177+
try {
178+
val files = droppedFiles(event)
179+
if (files.isEmpty()) {
180+
event.dropComplete(false)
181+
return
182+
}
183+
184+
panel.requestFocusInWindow()
185+
sendBracketedPaste(panel, files.joinToString(" ") { file -> shellQuoteIfNeeded(file.absolutePath) })
186+
event.dropComplete(true)
187+
} catch (e: Exception) {
188+
logger.warn("Failed to handle dropped file for OpenCode TUI", e)
189+
event.dropComplete(false)
190+
}
191+
}
192+
}, true)
193+
}
194+
195+
private fun sendBracketedPaste(panel: JBTerminalPanel, text: String) {
196+
panel.terminalOutputStream.sendString("\u001b[200~$text\u001b[201~", false)
197+
}
198+
199+
private fun shellQuoteIfNeeded(path: String): String {
200+
if (path.none { it.isWhitespace() }) return path
201+
return "'${path.replace("'", "'\\''")}'"
202+
}
203+
204+
private fun droppedFiles(event: DropTargetDropEvent): List<File> {
205+
val data = event.transferable.getTransferData(DataFlavor.javaFileListFlavor) as? List<*> ?: return emptyList()
206+
return data.filterIsInstance<File>()
207+
}
208+
156209
companion object {
157210
private val logger = Logger.getInstance(ClassicTuiPanel::class.java)
158211

src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import javax.swing.JComponent
1111

1212
private const val DATA_PROVIDER_CLIENT_PROPERTY = "DataProvider"
1313

14-
internal fun installEmbeddedTerminalDataProvider(project: Project, panel: JBTerminalPanel) {
14+
internal fun installEmbeddedTerminalDataProvider(
15+
project: Project,
16+
panel: JBTerminalPanel,
17+
) {
1518
// JBTerminalPanel's internal TerminalEscapeKeyListener moves focus back to the editor
1619
// whenever the terminal reports a ToolWindow in its data context. Our terminal is embedded
1720
// inside a custom tool window. Install the override on the terminal panel itself so the
@@ -21,6 +24,10 @@ internal fun installEmbeddedTerminalDataProvider(project: Project, panel: JBTerm
2124
installMarkdownTerminalHyperlinkFilter(project, panel)
2225
}
2326

27+
internal fun uninstallEmbeddedTerminalDataProvider(panel: JBTerminalPanel) {
28+
uninstallTerminalToolWindowOverride(panel)
29+
}
30+
2431
internal fun installTerminalToolWindowOverride(component: JComponent) {
2532
component.putClientProperty(DATA_PROVIDER_CLIENT_PROPERTY, DataProvider { dataId ->
2633
when {

0 commit comments

Comments
 (0)