Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-file-drop-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Fix dropping files into the JetBrains prompt so code files are added as readable file references and drops anywhere in the session panel feed the prompt attachments.
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,55 @@ data class PromptAttachment(
val mime: String,
val url: String,
val path: Path? = null,
val reference: Boolean = false,
) {
fun part() = PromptPartDto(
type = "file",
mime = mime,
url = path?.let { data(it, mime) } ?: url,
filename = name,
)
fun part(): PromptPartDto {
if (reference) {
return PromptPartDto(
type = "file",
mime = mime,
url = url,
filename = name,
)
}
return PromptPartDto(
type = "file",
mime = mime,
url = path?.let { data(it, mime) } ?: url,
filename = name,
)
}
}

object PromptAttachmentExtractor {
private const val MAX_BYTES = 10 * 1024 * 1024

fun files(files: List<java.io.File>): List<PromptAttachment> = files
.filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES }
.map { file ->
.filter { it.exists() && it.canRead() }
.mapNotNull { file ->
val path = file.toPath()
val mime = mime(file)
if (!media(mime)) return@map null
PromptAttachment(
if (image(mime)) {
if (!file.isFile || file.length() > MAX_BYTES) return@mapNotNull null
return@mapNotNull PromptAttachment(
id = path.toAbsolutePath().normalize().toString(),
name = path.fileName?.toString() ?: path.name,
mime = mime,
url = path.toUri().toString(),
path = path,
)
}
return@mapNotNull PromptAttachment(
id = path.toAbsolutePath().normalize().toString(),
name = path.fileName?.toString() ?: path.name,
mime = mime,
mime = if (file.isDirectory) mime else "text/plain",
Comment thread
kirillk marked this conversation as resolved.
url = path.toUri().toString(),
path = path,
reference = true,
)
}
.filterNotNull()

fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain"
fun image(mime: String): Boolean = mime.startsWith("image/")

fun image(raw: Any): PromptAttachment? {
val image = when (raw) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@ class PromptPanel(

@RequiresEdt
private fun addAttachment(item: PromptAttachment) {
if (!attachment && PromptAttachmentExtractor.media(item.mime)) {
if (!attachment && !item.reference && PromptAttachmentExtractor.image(item.mime)) {
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" }
notify(KiloBundle.message("prompt.attachment.unsupported.model"))
return
Expand Down Expand Up @@ -813,7 +813,15 @@ class PromptPanel(
val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image)
val ms = elapsedMs(start)
LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
if (items.isEmpty()) return@executeOnPooledThread
if (items.isEmpty()) {
if (list.isNotEmpty()) {
ApplicationManager.getApplication().invokeLater {
if (project.isDisposed) return@invokeLater
notify(KiloBundle.message("prompt.attachment.drop.empty"))
}
}
return@executeOnPooledThread
}
ApplicationManager.getApplication().invokeLater {
if (project.isDisposed) return@invokeLater
LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
Expand All @@ -827,7 +835,9 @@ class PromptPanel(

private fun dropFiles(event: DnDEvent): List<java.io.File> {
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList()
return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty()
val files = FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject)
if (files.isNotEmpty()) return files
return FileCopyPasteUtil.getFileList(event).orEmpty()
}

private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,8 @@ prompt.attachment.remove=Remove {0}
prompt.attachment.open=Open {0}
prompt.attachment.tooltip=Name: {0}\nType: {1}\nLocation: {2}
prompt.attachment.embedded=Embedded content
prompt.attachment.unsupported.model=The selected model does not support image or PDF attachments.
prompt.attachment.unsupported.model=The selected model does not support image attachments.
prompt.attachment.drop.empty=No files could be added.
prompt.attachment.missing=Attachment no longer exists: {0}
prompt.attachment.send.failed=Failed to send attachment: {0}
session.attachment.title=Attachment
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package ai.kilocode.client.session.model

import junit.framework.TestCase
import java.io.File
import kotlin.io.path.createTempDirectory

class PromptAttachmentExtractorTest : TestCase() {

fun `test code file becomes reference attachment`() {
val file = File.createTempFile("kilo-drop", ".php")
file.writeText("<?php echo 'hello';")

val item = PromptAttachmentExtractor.files(listOf(file)).single()
val part = item.part()

assertTrue(item.reference)
assertEquals("text/plain", item.mime)
assertEquals(file.toPath().toUri().toString(), item.url)
assertEquals(file.toPath(), item.path)
assertEquals(file.name, item.name)
assertEquals("file", part.type)
assertEquals("text/plain", part.mime)
assertEquals(file.toPath().toUri().toString(), part.url)
assertFalse(part.url.orEmpty().startsWith("data:"))
}

fun `test text file becomes reference attachment`() {
val file = File.createTempFile("kilo-drop", ".txt")
file.writeText("hello")

val item = PromptAttachmentExtractor.files(listOf(file)).single()

assertTrue(item.reference)
assertEquals("text/plain", item.mime)
assertEquals(file.toPath().toUri().toString(), item.part().url)
}

fun `test directory becomes reference attachment`() {
val dir = createTempDirectory(prefix = "kilo-drop").toFile()

val item = PromptAttachmentExtractor.files(listOf(dir)).single()
val part = item.part()

assertTrue(item.reference)
assertEquals("application/x-directory", item.mime)
assertEquals(dir.toPath().toUri().toString(), item.url)
assertEquals(dir.toPath(), item.path)
assertEquals(dir.name, item.name)
assertEquals("file", part.type)
assertEquals("application/x-directory", part.mime)
assertEquals(dir.toPath().toUri().toString(), part.url)
}

fun `test image file remains embedded attachment`() {
val file = File.createTempFile("kilo-drop", ".png")
file.writeBytes(byteArrayOf(1, 2, 3))

val item = PromptAttachmentExtractor.files(listOf(file)).single()

assertFalse(item.reference)
assertEquals("image/png", item.mime)
assertEquals(file.toPath().toUri().toString(), item.url)
assertTrue(item.part().url.orEmpty().startsWith("data:image/png;base64,"))
}

fun `test oversized image is skipped but oversized code is referenced`() {
val image = File.createTempFile("kilo-drop", ".png")
image.writeBytes(ByteArray(10 * 1024 * 1024 + 1))
val code = File.createTempFile("kilo-drop", ".php")
code.writeBytes(ByteArray(10 * 1024 * 1024 + 1))

val items = PromptAttachmentExtractor.files(listOf(image, code))

assertEquals(listOf(code.name), items.map { it.name })
assertTrue(items.single().reference)
}

fun `test nonexistent file is ignored`() {
val file = File(File(System.getProperty("java.io.tmpdir")), "kilo-missing-${System.nanoTime()}.php")

assertTrue(PromptAttachmentExtractor.files(listOf(file)).isEmpty())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -966,17 +966,18 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals(1, panel.attachmentCountForTest())
}

fun `test frontend file attachment defers data url encoding until send`() {
fun `test frontend text file attachment stays file reference`() {
val file = File.createTempFile("kilo-paste", ".txt")
file.writeText("hello")

val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single()

assertTrue(item.reference)
assertTrue(item.url.startsWith("file://"))
assertTrue(item.part().url.orEmpty().startsWith("data:text/plain;base64,"))
assertEquals(item.url, item.part().url)
}

fun `test pasted frontend file sends data url payload`() {
fun `test pasted frontend file sends reference payload`() {
var sent: ai.kilocode.rpc.dto.PromptPartDto? = null
val panel = PromptPanel(project, { _, files -> sent = files.single() }, {}, { _, _ -> })
val file = File.createTempFile("kilo-paste", ".txt")
Expand All @@ -990,8 +991,7 @@ class PromptPanelTest : BasePlatformTestCase() {

val item = sent!!
assertEquals("text/plain", item.mime)
assertTrue(item.url.orEmpty().startsWith("data:text/plain;base64,"))
assertFalse(item.url.orEmpty().startsWith("file://"))
assertEquals(file.toPath().toUri().toString(), item.url)
}

fun `test raw image paste adds attachment`() {
Expand Down Expand Up @@ -1038,6 +1038,18 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals(0, panel.attachmentCountForTest())
}

fun `test disabled media model allows file reference attachment`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
val file = File.createTempFile("kilo-paste", ".php")
file.writeText("<?php echo 'hello';")
val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single()
panel.setAttachmentEnabled(false)

panel.addAttachmentForTest(item)

assertEquals(1, panel.attachmentCountForTest())
}

fun `test prompt button switches between send and stop state`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })

Expand Down
Loading