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-checklist-styling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Render JetBrains todo checklists with consistent text weight and higher-contrast checkboxes.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-inline-code-foreground.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Match JetBrains inline code and file-reference link styling with VS Code, and render quotes with muted theme-aware styling.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-popups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Show JetBrains missing-file warnings without animation and always show shell command header popups for collapsed shell runs.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-session-file-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Open JetBrains session file links in the active workspace and hide sibling worktree matches.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-session-link-hover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Clear stale session link hover styling when the transcript is scrolled.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-tool-header-clipping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Keep JetBrains tool headers to a single clipped line.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import ai.kilocode.backend.workspace.KiloWorkspaceState
import ai.kilocode.log.KiloLog
import ai.kilocode.jetbrains.api.model.Agent
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.isManagedWorktreeStorage
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
Expand Down Expand Up @@ -181,17 +182,11 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
override suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
val item = clean(path) ?: return emptyList()
val file = file(item) ?: return emptyList()
val bases = listOf(directory) + ProjectManager.getInstance().openProjects
.asSequence()
.filter { !it.isDefault }
.mapNotNull { it.basePath }
.filter { it != directory }
.toList()
val paths = if (file.isAbsolute) listOf(file) else bases.mapNotNull { base ->
file(base)?.resolve(file)?.normalize()
}
val base = file(clean(directory) ?: directory) ?: return emptyList()
val paths = if (file.isAbsolute) listOf(file) else listOf(base.resolve(file).normalize())
val found = linkedMapOf<String, WorkspaceFileDto>()
for (target in paths) {
relativeWithinWorkspace(base, target) ?: continue
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: continue
found[vf.path] = WorkspaceFileDto(vf.path, vf.name, vf.isDirectory)
}
Expand Down Expand Up @@ -223,15 +218,15 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
text.takeIf { it.isNotBlank() }?.take(DIFF_CAP)
}

override suspend fun openFile(path: String): Boolean {
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
val item = clean(path) ?: return false
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false
val project = project(target) ?: run {
LOG.warn("No project available to open file: $path")
return false
}
navigate(project, vf)
navigate(project, vf, line, column)
return true
}

Expand Down Expand Up @@ -302,9 +297,19 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
null
}

private suspend fun navigate(project: Project, file: VirtualFile) = suspendCancellableCoroutine { cont ->
private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null) = suspendCancellableCoroutine { cont ->
ApplicationManager.getApplication().invokeLater({
OpenFileDescriptor(project, file).navigate(true)
val descriptor = if (line == null) {
OpenFileDescriptor(project, file)
} else {
OpenFileDescriptor(
project,
file,
(line - 1).coerceAtLeast(0),
(column?.minus(1))?.coerceAtLeast(0) ?: 0,
)
}
descriptor.navigate(true)
if (cont.isActive) cont.resume(Unit)
}, ModalityState.any())
}
Expand All @@ -329,7 +334,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
override fun acceptItem(item: NavigationItem): Boolean {
val psi = item as? PsiFileSystemItem ?: return false
val path = file(psi.virtualFile.path) ?: return false
return path.startsWith(base) && super.acceptItem(item)
return relativeWithinWorkspace(base, path) != null && super.acceptItem(item)
}

override fun loadInitialCheckBoxState(): Boolean = false
Expand Down Expand Up @@ -390,7 +395,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {

private fun fileDto(base: Path, vf: VirtualFile): WorkspaceFileDto? {
val path = file(vf.path) ?: return null
val rel = relativeWithinBase(base, path) ?: return null
val rel = relativeWithinWorkspace(base, path) ?: return null
return WorkspaceFileDto(rel, vf.name, vf.isDirectory)
}

Expand Down Expand Up @@ -488,3 +493,9 @@ internal fun relativeWithinBase(base: Path, target: Path): String? {
val rel = base.relativize(path).toString().replace('\\', '/')
return rel.ifBlank { null }
}

internal fun relativeWithinWorkspace(base: Path, target: Path): String? {
val rel = relativeWithinBase(base, target) ?: return null
if (isManagedWorktreeStorage(rel)) return null
return rel
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,40 @@ class WorkspacePathScopingTest {
assertNull(relativeWithinBase(base, sibling.resolve("A.kt")))
}

@Test
fun `workspace scope keeps normal project and kilo plan files`() {
assertEquals("backend/src/Main.java", relativeWithinWorkspace(base, at("backend", "src", "Main.java")))
assertEquals(".kilo/plans/x.md", relativeWithinWorkspace(base, at(".kilo", "plans", "x.md")))
}

@Test
fun `workspace scope rejects managed worktree storage from main checkout`() {
assertNull(relativeWithinWorkspace(base, at(".kilo", "worktrees")))
assertNull(relativeWithinWorkspace(base, at(".kilo", "worktrees", "foo", "backend", "src", "Main.java")))
}

@Test
fun `workspace scope allows files inside the active worktree`() {
val root = at(".kilo", "worktrees", "foo")

assertEquals("backend/src/Main.java", relativeWithinWorkspace(root, root.resolve("backend/src/Main.java")))
}

@Test
fun `workspace scope rejects sibling worktrees from active worktree`() {
val root = at(".kilo", "worktrees", "foo")
val sibling = at(".kilo", "worktrees", "bar", "backend", "src", "Main.java")

assertNull(relativeWithinWorkspace(root, sibling))
}

@Test
fun `workspace scope rejects nested managed worktree storage`() {
val root = at(".kilo", "worktrees", "foo")

assertNull(relativeWithinWorkspace(root, root.resolve(".kilo/worktrees/bar/backend/src/Main.java")))
}

@Test
fun `normalizes encoded file URLs`() {
val path = base.resolve("dir with spaces").resolve("A.kt")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,10 @@ class KiloWorkspaceService internal constructor(
}
}

suspend fun openPath(directory: String, path: String): Boolean {
suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean {
val match = files(directory, path).firstOrNull() ?: return false
return try {
call { openFile(match.path) }
call { openFile(match.path, line, column) }
} catch (e: Exception) {
LOG.warn("workspace file open failed for path=${match.path}", e)
false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package ai.kilocode.client.session

import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.rpc.isManagedWorktreeStorage
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.ui.MessageType
import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.xml.util.XmlStringUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JList

typealias SessionFileOpener = (href: String, anchor: RelativePoint?) -> Unit

fun MdView.LinkEvent.anchor(): RelativePoint? {
val component = component ?: return null
val point = point ?: return null
return RelativePoint(component, point)
}

fun openSessionLink(event: MdView.LinkEvent, openFile: SessionFileOpener, openUrl: (String) -> Unit) {
if (SessionFileLinks.isFileHref(event.href)) {
openFile(event.href, event.anchor())
return
}
openUrl(event.href)
}

class SessionFileLinks(
private val dir: String,
private val service: KiloWorkspaceService,
private val scope: CoroutineScope,
private val root: JComponent,
private val openUrl: (String) -> Unit,
private val send: (String, Map<String, String>) -> Unit = Telemetry::send,
) {
fun open(href: String, anchor: RelativePoint?) {
if (!isFileHref(href)) {
openUrl(href)
return
}
val target = parse(href)
scope.launch {
val ok = service.openPath(dir, target.path, target.line, target.column)
if (ok) {
track(target, "direct")
return@launch
}
val found = service.searchFiles(dir, decode(name(target.path)), FILE_SEARCH_LIMIT)
.files
.filterNot { it.directory }
.filterNot { isManagedWorktreeStorage(it.path) }
.ranked(target.path)
when (val result = decide(false, found)) {
Resolution.Opened -> Unit
is Resolution.OpenDirect -> {
val opened = service.openPath(dir, result.file.path, target.line, target.column)
track(target, if (opened) "search_direct" else "missing")
}
is Resolution.Choose -> {
track(target, "chooser")
withContext(Dispatchers.Main) { choose(result.files, target, anchor) }
}
Resolution.Missing -> {
track(target, "missing")
withContext(Dispatchers.Main) { missing(target.path, anchor) }
}
}
}
}

private fun track(target: Target, result: String) = send(
"File Link Opened",
mapOf(
"surface" to "session",
"kind" to "file",
"hasLine" to (target.line != null).toString(),
"hasColumn" to (target.column != null).toString(),
"result" to result,
),
)

@RequiresEdt
private fun choose(files: List<WorkspaceFileDto>, target: Target, anchor: RelativePoint?) {
val popup = JBPopupFactory.getInstance()
.createPopupChooserBuilder(files)
.setRenderer(FileRenderer())
.setItemChosenCallback { file ->
scope.launch { service.openPath(dir, file.path, target.line, target.column) }
}
.createPopup()
popup.show(anchor ?: RelativePoint.getCenterOf(root))
}

@RequiresEdt
private fun missing(path: String, anchor: RelativePoint?) {
JBPopupFactory.getInstance()
.createHtmlTextBalloonBuilder(KiloBundle.message("session.file.missing", XmlStringUtil.escapeString(path)), MessageType.WARNING, null)
.setAnimationCycle(0)
.createBalloon()
.also { it.setAnimationEnabled(false) }
.show(anchor ?: RelativePoint.getCenterOf(root), Balloon.Position.above)
}

private class FileRenderer : ColoredListCellRenderer<WorkspaceFileDto>() {
override fun customizeCellRenderer(
list: JList<out WorkspaceFileDto>,
value: WorkspaceFileDto?,
index: Int,
selected: Boolean,
hasFocus: Boolean,
) {
val file = value ?: return
icon = icon(file)
append(file.name)
val parent = parent(file.path)
if (parent.isNotBlank()) append(" $parent", SimpleTextAttributes.GRAYED_ATTRIBUTES)
}
}

sealed interface Resolution {
data object Opened : Resolution
data class OpenDirect(val file: WorkspaceFileDto) : Resolution
data class Choose(val files: List<WorkspaceFileDto>) : Resolution
data object Missing : Resolution
}

data class Target(val path: String, val line: Int? = null, val column: Int? = null)

companion object {
private const val FILE_SEARCH_LIMIT = 50
private val LINE = Regex(":(\\d+)(?:-\\d+)?(?::(\\d+))?$")
private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.-]*):")

fun parse(href: String): Target {
val match = LINE.find(href) ?: return Target(href)
return Target(
href.substring(0, match.range.first),
match.groupValues[1].toIntOrNull(),
match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull(),
)
}

fun isFileHref(href: String): Boolean {
val scheme = SCHEME.find(href)?.groupValues?.getOrNull(1) ?: return true
if (scheme.length == 1) return true
return scheme.equals("file", ignoreCase = true)
}

fun decide(openOk: Boolean, candidates: List<WorkspaceFileDto>): Resolution {
if (openOk) return Resolution.Opened
if (candidates.isEmpty()) return Resolution.Missing
if (candidates.size == 1) return Resolution.OpenDirect(candidates.single())
return Resolution.Choose(candidates)
}

private fun icon(file: WorkspaceFileDto): Icon = when {
file.directory -> AllIcons.Nodes.Folder
else -> FileTypeManager.getInstance().getFileTypeByFileName(file.name).icon ?: AllIcons.FileTypes.Text
}

private fun name(path: String): String {
val clean = path.trimEnd('/', '\\')
val idx = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\'))
if (idx < 0) return clean
return clean.substring(idx + 1)
}

private fun parent(path: String): String {
val idx = path.lastIndexOf('/')
if (idx <= 0) return ""
return path.substring(0, idx)
}

private fun decode(value: String): String = runCatching {
URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8)
}.getOrDefault(value)

private fun List<WorkspaceFileDto>.ranked(path: String): List<WorkspaceFileDto> {
val target = path.trimStart('/', '\\')
return sortedByDescending { it.path == target || it.path.endsWith("/$target") }
}
}
}
Loading
Loading