fix(jetbrains): align permission rules with vscode
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Honor saved JetBrains bash permission rules when running with isolated dev storage.
|
||||
@@ -581,7 +581,7 @@ internal suspend fun awaitReady(
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}}"""
|
||||
private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask"}}"""
|
||||
|
||||
// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs).
|
||||
internal fun buildKiloCliEnv(
|
||||
|
||||
@@ -1166,7 +1166,7 @@ object KiloCliDataParser {
|
||||
}?.toMap() ?: emptyMap()
|
||||
val path = metaObj.path()
|
||||
val diffs = metaObj.permissionDiffs(path)
|
||||
val rules = metaObj.rules().ifEmpty { bashHierarchy(permission, patterns, always) }
|
||||
val rules = metaObj.rules()
|
||||
return PermissionRequestDto(
|
||||
id = id,
|
||||
sessionID = sid,
|
||||
@@ -1645,24 +1645,6 @@ private fun JsonObject.decision(): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun bashHierarchy(permission: String, patterns: List<String>, always: List<String>): List<String> {
|
||||
if (permission != "bash") return emptyList()
|
||||
val out = linkedSetOf<String>()
|
||||
val prefixes = always.mapNotNull { item -> item.removeSuffix(" *").takeIf { it != item && it.isNotBlank() } }
|
||||
for (pattern in patterns) {
|
||||
val text = pattern.trim()
|
||||
if (text.isBlank()) continue
|
||||
val prefix = prefixes
|
||||
.sortedByDescending { it.length }
|
||||
.firstOrNull { text == it || text.startsWith("$it ") }
|
||||
?: text.substringBefore(' ')
|
||||
val parts = prefix.split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
for (i in 1..parts.size) out.add(parts.take(i).joinToString(" ") + " *")
|
||||
if (text != prefix) out.add(text)
|
||||
}
|
||||
return out.ifEmpty { always }.toList()
|
||||
}
|
||||
|
||||
private fun JsonObject?.permissionDiffs(path: String?): List<PermissionFileDiffDto> {
|
||||
if (this == null) return emptyList()
|
||||
val filediff = this["filediff"].obj()
|
||||
|
||||
@@ -238,6 +238,13 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
|
||||
target(globalConfig())
|
||||
}
|
||||
|
||||
override suspend fun refreshConfigFiles(directory: String) {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
listOf(localConfig(directory), globalConfig()).map { it.toFile() }
|
||||
}
|
||||
LocalFileSystem.getInstance().refreshIoFiles(files, true, true, null)
|
||||
}
|
||||
|
||||
override suspend fun openLocalConfig(directory: String): Boolean = openConfig(withContext(Dispatchers.IO) {
|
||||
localConfig(directory)
|
||||
})
|
||||
|
||||
@@ -64,10 +64,10 @@ class KiloBackendCliManagerEnvTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isolation disabled - default CLI config asks for edit and bash permissions`() {
|
||||
fun `isolation disabled - default CLI config asks for edit permissions`() {
|
||||
val env = manager.buildEnv("pwd123", emptyMap())
|
||||
|
||||
assertEquals("""{"permission":{"edit":"ask","bash":"ask"}}""", env["KILO_CONFIG_CONTENT"])
|
||||
assertEquals("""{"permission":{"edit":"ask"}}""", env["KILO_CONFIG_CONTENT"])
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2366,7 +2366,7 @@ class KiloCliDataParserTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parsePermissionRequest - derives bash hierarchy from always prefix`() {
|
||||
fun `parsePermissionRequest - uses always when metadata rules are absent`() {
|
||||
val data = globalEvent("""
|
||||
"type": "permission.asked",
|
||||
"properties": {
|
||||
@@ -2382,9 +2382,9 @@ class KiloCliDataParserTest {
|
||||
val result = KiloCliDataParser.parseChatEvent("permission.asked", data)
|
||||
assertNotNull(result)
|
||||
val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked")
|
||||
assertEquals(listOf("git *", "git add *", "git add ."), asked.request.rules)
|
||||
assertEquals(listOf("git *", "git add *", "git add ."), asked.request.ruleDecisions.map { it.pattern })
|
||||
assertEquals(listOf("pending", "pending", "pending"), asked.request.ruleDecisions.map { it.decision })
|
||||
assertEquals(emptyList(), asked.request.rules)
|
||||
assertEquals(listOf("git add *"), asked.request.ruleDecisions.map { it.pattern })
|
||||
assertEquals(listOf("pending"), asked.request.ruleDecisions.map { it.decision })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -83,7 +83,7 @@ class KiloWorkspaceService internal constructor(
|
||||
LOG.info("Creating workspace for $directory")
|
||||
val state = stream { state(directory) }
|
||||
.stateIn(cs, SharingStarted.Eagerly, INIT)
|
||||
Workspace(directory, state) { reload(directory) }
|
||||
Workspace(directory, state, { reload(directory) }) { refreshConfigFiles(directory) }
|
||||
}
|
||||
// Refresh on every workspace access so config actions reflect file system changes.
|
||||
refreshLocalConfigTarget(directory)
|
||||
@@ -216,6 +216,15 @@ class KiloWorkspaceService internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshConfigFiles(directory: String) {
|
||||
cs.launch {
|
||||
call { refreshConfigFiles(directory) }
|
||||
localConfigTarget(directory)
|
||||
globalConfigTarget()
|
||||
ActivityTracker.getInstance().inc()
|
||||
}
|
||||
}
|
||||
|
||||
fun openLocalConfig(directory: String, done: (Boolean) -> Unit) {
|
||||
cs.launch {
|
||||
val ok = try {
|
||||
|
||||
@@ -14,4 +14,5 @@ class Workspace(
|
||||
val directory: String,
|
||||
val state: StateFlow<KiloWorkspaceStateDto>,
|
||||
val reload: () -> Unit,
|
||||
val refreshConfigFiles: () -> Unit = {},
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.intellij.ide.util.PropertiesComponent
|
||||
|
||||
object KiloPluginSettings {
|
||||
private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove"
|
||||
private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded"
|
||||
|
||||
fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false)
|
||||
|
||||
@@ -14,4 +15,14 @@ object KiloPluginSettings {
|
||||
internal fun unsetAutoApprove() {
|
||||
PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY)
|
||||
}
|
||||
|
||||
fun getPermissionRulesExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(PERMISSION_RULES_EXPANDED_KEY, false)
|
||||
|
||||
fun setPermissionRulesExpanded(value: Boolean) {
|
||||
PropertiesComponent.getInstance().setValue(PERMISSION_RULES_EXPANDED_KEY, value.toString())
|
||||
}
|
||||
|
||||
internal fun unsetPermissionRulesExpanded() {
|
||||
PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ class SessionUi(
|
||||
focus = focus,
|
||||
)
|
||||
permission = PermissionView(
|
||||
reply = { id, dto -> controller.replyPermission(id, dto) },
|
||||
reply = { id, dto, rules -> controller.replyPermission(id, dto, rules) },
|
||||
selection = selection,
|
||||
focus = focus,
|
||||
)
|
||||
|
||||
@@ -661,6 +661,7 @@ class SessionController(
|
||||
cs.launch {
|
||||
try {
|
||||
if (rules != null) sessions.savePermissionRules(requestId, directory, rules)
|
||||
if (rules != null) workspace.refreshConfigFiles()
|
||||
sessions.replyPermission(requestId, directory, reply)
|
||||
capture("Approval Answered", sessionProps() + mapOf(
|
||||
"requestId" to requestId,
|
||||
|
||||
@@ -19,6 +19,10 @@ object SessionViewIcons {
|
||||
val eye = icon("eye")
|
||||
val glasses = icon("glasses")
|
||||
val mcp = icon("mcp")
|
||||
val ruleApprove = icon("check-small")
|
||||
val ruleApproveActive = icon("check-small-active")
|
||||
val ruleDeny = icon("close-small")
|
||||
val ruleDenyActive = icon("close-small-active")
|
||||
val search = icon("magnifying-glass-menu")
|
||||
val task = icon("task")
|
||||
val warning = icon("warning")
|
||||
|
||||
@@ -1,39 +1,66 @@
|
||||
package ai.kilocode.client.session.views.permission
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.plugin.KiloPluginSettings
|
||||
import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionFileDiff
|
||||
import ai.kilocode.client.session.model.PermissionRuleCandidate
|
||||
import ai.kilocode.client.session.model.PermissionRuleDecision
|
||||
import ai.kilocode.client.session.model.PermissionRequestState
|
||||
import ai.kilocode.client.session.ui.SessionView
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.iconButton
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.StackAxis
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.client.ui.md.MdCodeBlockBorder
|
||||
import ai.kilocode.client.ui.md.MdCodeBlockFactory
|
||||
import ai.kilocode.client.ui.md.MdCodeBlockOptions
|
||||
import ai.kilocode.client.ui.md.MdView
|
||||
import ai.kilocode.client.ui.md.MdViewFactory
|
||||
import ai.kilocode.client.ui.md.hybrid.MdShellHighlight
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.editor.markup.HighlighterLayer
|
||||
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
||||
import com.intellij.openapi.fileTypes.PlainTextFileType
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.ColorUtil
|
||||
import com.intellij.ui.components.JBHtmlPane
|
||||
import com.intellij.ui.components.JBHtmlPaneConfiguration
|
||||
import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import com.intellij.xml.util.XmlStringUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Dimension
|
||||
import java.awt.Container
|
||||
import java.awt.Cursor
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Rectangle
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.text.html.StyleSheet
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
/**
|
||||
* Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel]
|
||||
@@ -43,10 +70,10 @@ import javax.swing.text.html.StyleSheet
|
||||
* Shows a compact row with action label and target as an inline code fragment, plus diff badges.
|
||||
*/
|
||||
class PermissionView(
|
||||
private val reply: (String, PermissionReplyDto) -> Unit,
|
||||
private val reply: (String, PermissionReplyDto, PermissionAlwaysRulesDto?) -> Unit,
|
||||
private val selection: SessionSelection? = null,
|
||||
focus: (() -> Unit)? = null,
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView, Disposable {
|
||||
override val sessionViewKind = SessionView.Kind.Default
|
||||
|
||||
private var requestId: String? = null
|
||||
@@ -54,11 +81,17 @@ class PermissionView(
|
||||
|
||||
private val card = BaseQuestionView(selection, focus)
|
||||
|
||||
private val body = Stack.vertical()
|
||||
private val body = Stack.vertical(gap = UiStyle.Gap.sm())
|
||||
private val desc = makeDescription()
|
||||
private val codeSlot = BorderLayoutPanel().apply { isVisible = false }
|
||||
private val diffRow = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isVisible = false }
|
||||
private val rules = PermissionRulesView(selection) { syncPrimaryText() }.apply { isVisible = false }
|
||||
private val state = JBLabel().apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
// Track target panes for style updates
|
||||
private val panes = mutableListOf<JBHtmlPane>()
|
||||
private val regs = mutableListOf<Disposable>()
|
||||
private var md: MdView? = null
|
||||
private val diffViews = mutableListOf<PermissionDiffView>()
|
||||
|
||||
private val ID_DENY = "deny"
|
||||
@@ -68,125 +101,91 @@ class PermissionView(
|
||||
isOpaque = false
|
||||
isVisible = false
|
||||
|
||||
card.setHeaderIcon(SessionViewIcons.warning, KiloBundle.message("session.permission.title"))
|
||||
card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title"))
|
||||
card.setContent(body)
|
||||
card.setActions(listOf(
|
||||
BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") },
|
||||
BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.run"), primary = true) { decide("once") },
|
||||
))
|
||||
body.next(desc).next(codeSlot).next(diffRow).next(rules).next(state)
|
||||
card.setActions(
|
||||
listOf(
|
||||
BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.reject"), primary = false) { reject() },
|
||||
BaseQuestionView.Action(ID_RUN, KiloBundle.message("session.permission.allow.once"), primary = true) { allow() },
|
||||
),
|
||||
)
|
||||
addToCenter(card)
|
||||
}
|
||||
|
||||
/** Populate the view for [permission] and make it visible. */
|
||||
@RequiresEdt
|
||||
fun show(permission: Permission) {
|
||||
val prev = requestId
|
||||
requestId = permission.id
|
||||
|
||||
card.setHeader(KiloBundle.message("session.permission.title"))
|
||||
|
||||
body.removeAll()
|
||||
disposeRegs()
|
||||
panes.clear()
|
||||
diffViews.clear()
|
||||
syncDescription(description(permission))
|
||||
|
||||
val tool = permission.name
|
||||
val cmd = permission.meta.command
|
||||
|
||||
val action = toolLabel(tool)
|
||||
val target = cmd ?: resolveTarget(permission)
|
||||
addDetailRow(action, target, permission.meta.fileDiffs)
|
||||
addStateMessage(permission)
|
||||
val target = if (tool == "bash") permission.meta.command else resolveTarget(permission)
|
||||
syncCode(tool, target)
|
||||
syncDiffs(permission.meta.fileDiffs)
|
||||
rules.update(permission.meta.ruleDecisions, reset = prev != permission.id)
|
||||
syncState(permission)
|
||||
syncPrimaryText()
|
||||
|
||||
val responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED
|
||||
card.setActionEnabled(ID_RUN, !responding)
|
||||
card.setActionEnabled(ID_DENY, !responding)
|
||||
rules.setControlsEnabled(!responding)
|
||||
|
||||
isVisible = true
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Hide this view and clear the active request id. */
|
||||
@RequiresEdt
|
||||
fun hideView() {
|
||||
requestId = null
|
||||
body.removeAll()
|
||||
disposeRegs()
|
||||
panes.clear()
|
||||
disposeMd()
|
||||
diffViews.clear()
|
||||
diffRow.removeAll()
|
||||
diffRow.isVisible = false
|
||||
rules.update(emptyList(), reset = true)
|
||||
state.isVisible = false
|
||||
isVisible = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
card.applyStyle(style)
|
||||
for (pane in panes) {
|
||||
applyTargetPane(pane)
|
||||
}
|
||||
desc.font = style.hintFont
|
||||
desc.foreground = UiStyle.Colors.weak()
|
||||
rules.applyStyle(style)
|
||||
md?.let { applyCodeStyle(it) }
|
||||
for (dv in diffViews) {
|
||||
dv.applyStyle(style)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a three-column permission detail row: tool, target, and changes. */
|
||||
private fun addDetailRow(action: String, target: String?, diffs: List<PermissionFileDiff>) {
|
||||
val row = JPanel(BorderLayout(SessionUiStyle.View.Layout.GAP, 0)).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
val actionLbl = JBLabel(action).apply {
|
||||
font = UiStyle.Fonts.bold()
|
||||
}
|
||||
row.add(actionLbl.align(HAlign.LEFT, VAlign.CENTER), BorderLayout.WEST)
|
||||
|
||||
if (!target.isNullOrBlank()) {
|
||||
val pane = targetPane(target)
|
||||
panes.add(pane)
|
||||
row.add(pane.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER)
|
||||
}
|
||||
@RequiresEdt
|
||||
private fun syncDescription(text: String) {
|
||||
if (desc.text != text) desc.text = text
|
||||
desc.isVisible = text.isNotBlank()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncDiffs(diffs: List<PermissionFileDiff>) {
|
||||
diffRow.removeAll()
|
||||
diffViews.clear()
|
||||
diffRow.isVisible = diffs.isNotEmpty()
|
||||
if (diffs.isNotEmpty()) {
|
||||
val changes = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
for (diff in diffs) {
|
||||
val dv = PermissionDiffView(diff)
|
||||
diffViews.add(dv)
|
||||
changes.add(dv)
|
||||
diffRow.add(dv)
|
||||
}
|
||||
row.add(changes.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
|
||||
}
|
||||
|
||||
body.add(row)
|
||||
}
|
||||
|
||||
private fun targetPane(text: String) = JBHtmlPane(
|
||||
JBHtmlPaneStyleConfiguration {},
|
||||
JBHtmlPaneConfiguration {
|
||||
customStyleSheetProvider { targetSheet() }
|
||||
},
|
||||
).apply {
|
||||
isEditable = false
|
||||
isOpaque = true
|
||||
this.text = "<html><body><pre>${XmlStringUtil.escapeString(text)}</pre></body></html>"
|
||||
applyTargetPane(this)
|
||||
selection?.register(this)?.let(regs::add)
|
||||
}
|
||||
|
||||
private fun applyTargetPane(pane: JBHtmlPane) {
|
||||
pane.font = style.transcriptFont
|
||||
pane.foreground = style.editorForeground
|
||||
pane.background = SessionUiStyle.View.Surface.headerHoverBgColor()
|
||||
pane.reloadCssStylesheets()
|
||||
}
|
||||
|
||||
private fun targetSheet(): StyleSheet {
|
||||
val sheet = StyleSheet()
|
||||
val font = style.transcriptFont
|
||||
val fg = ColorUtil.toHtmlColor(style.editorForeground)
|
||||
val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.Surface.headerHoverBgColor())
|
||||
val family = font.name.replace("\\", "\\\\").replace("'", "\\'")
|
||||
sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }")
|
||||
sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }")
|
||||
return sheet
|
||||
diffRow.revalidate()
|
||||
diffRow.repaint()
|
||||
}
|
||||
|
||||
private fun resolveTarget(permission: Permission): String? {
|
||||
@@ -201,19 +200,127 @@ class PermissionView(
|
||||
}
|
||||
}
|
||||
|
||||
private fun addStateMessage(permission: Permission) {
|
||||
@RequiresEdt
|
||||
private fun syncState(permission: Permission) {
|
||||
val msg = when (permission.state) {
|
||||
PermissionRequestState.ERROR ->
|
||||
permission.message ?: KiloBundle.message("session.permission.error")
|
||||
PermissionRequestState.RESPONDING ->
|
||||
KiloBundle.message("session.permission.responding")
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
val label = JBLabel(msg).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
|
||||
}
|
||||
body.add(label)
|
||||
state.text = msg.orEmpty()
|
||||
state.isVisible = msg != null
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncCode(tool: String, target: String?) {
|
||||
if (target.isNullOrBlank()) {
|
||||
codeSlot.isVisible = false
|
||||
md?.clear()
|
||||
return
|
||||
}
|
||||
|
||||
val view = ensureMd()
|
||||
val lang = if (tool == "bash") "shell-command" else ""
|
||||
val text = fenced(target, lang)
|
||||
if (view.markdown() != text) view.set(text)
|
||||
applyCodeStyle(view)
|
||||
codeSlot.isVisible = true
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun ensureMd(): MdView {
|
||||
md?.let { return it }
|
||||
val view = MdViewFactory.create(
|
||||
style,
|
||||
selection,
|
||||
MdCodeBlockFactory.default(
|
||||
MdCodeBlockOptions(
|
||||
border = MdCodeBlockBorder.None,
|
||||
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
editorOnly = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
md = view
|
||||
applyCodeStyle(view)
|
||||
codeSlot.add(view.component, BorderLayout.CENTER)
|
||||
return view
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun applyCodeStyle(view: MdView) {
|
||||
view.applyStyle(style)
|
||||
view.font = style.transcriptFont
|
||||
view.foreground = style.editorForeground
|
||||
view.background = style.editorBackground
|
||||
view.preBg = style.editorBackground
|
||||
view.codeFont = style.editorFamily
|
||||
view.component.border = JBUI.Borders.empty()
|
||||
}
|
||||
|
||||
private fun description(permission: Permission): String = if (permission.name == "bash") {
|
||||
permission.meta.raw["description"] ?: toolLabel(permission.name)
|
||||
} else {
|
||||
toolLabel(permission.name)
|
||||
}
|
||||
|
||||
private fun makeDescription(): JBTextArea {
|
||||
val area = object : JBTextArea() {
|
||||
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
|
||||
|
||||
override fun getMaximumSize(): Dimension {
|
||||
val size = preferredSize
|
||||
return Dimension(Int.MAX_VALUE, size.height)
|
||||
}
|
||||
|
||||
override fun scrollRectToVisible(aRect: Rectangle) {}
|
||||
|
||||
private fun withWidth(fallback: Int): Dimension {
|
||||
val w = availableWidth()
|
||||
if (w <= 0) return Dimension(super.getPreferredSize().width, fallback)
|
||||
val old = size
|
||||
setSize(w, Int.MAX_VALUE)
|
||||
val ps = super.getPreferredSize()
|
||||
setSize(old)
|
||||
return Dimension(w, ps.height)
|
||||
}
|
||||
|
||||
private fun availableWidth(): Int {
|
||||
var node = parent
|
||||
while (node != null) {
|
||||
if (node.width > 0) {
|
||||
val ins = node.insets
|
||||
return (node.width - ins.left - ins.right).coerceAtLeast(0)
|
||||
}
|
||||
node = node.parent
|
||||
}
|
||||
return width
|
||||
}
|
||||
}.apply {
|
||||
isEditable = false
|
||||
isOpaque = false
|
||||
isFocusable = false
|
||||
caret.isVisible = false
|
||||
caret.isSelectionVisible = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
foreground = UiStyle.Colors.weak()
|
||||
font = style.hintFont
|
||||
border = JBUI.Borders.empty()
|
||||
isVisible = false
|
||||
}
|
||||
selection?.register(area)
|
||||
return area
|
||||
}
|
||||
|
||||
private fun fenced(text: String, lang: String): String = buildString {
|
||||
val fence = fence(text)
|
||||
append(fence).append(lang).append('\n')
|
||||
append(text)
|
||||
if (!text.endsWith('\n')) append('\n')
|
||||
append(fence)
|
||||
}
|
||||
|
||||
private fun toolLabel(tool: String): String = when (tool) {
|
||||
@@ -238,11 +345,36 @@ class PermissionView(
|
||||
else -> tool
|
||||
}
|
||||
|
||||
private fun decide(value: String) {
|
||||
@RequiresEdt
|
||||
private fun allow() {
|
||||
val id = requestId ?: return
|
||||
card.setActionEnabled(ID_RUN, false)
|
||||
card.setActionEnabled(ID_DENY, false)
|
||||
reply(id, PermissionReplyDto(reply = value))
|
||||
rules.setControlsEnabled(false)
|
||||
reply(id, PermissionReplyDto(reply = "once"), rulePayload())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun reject() {
|
||||
val id = requestId ?: return
|
||||
card.setActionEnabled(ID_RUN, false)
|
||||
card.setActionEnabled(ID_DENY, false)
|
||||
rules.setControlsEnabled(false)
|
||||
reply(id, PermissionReplyDto(reply = "reject"), rulePayload())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun rulePayload(): PermissionAlwaysRulesDto? {
|
||||
if (!rules.anyDecided()) return null
|
||||
return PermissionAlwaysRulesDto(approvedAlways = rules.approved(), deniedAlways = rules.denied())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncPrimaryText() {
|
||||
card.setActionText(
|
||||
ID_RUN,
|
||||
KiloBundle.message(if (rules.anyDecided()) "session.permission.apply.allow" else "session.permission.allow.once"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
@@ -252,17 +384,36 @@ class PermissionView(
|
||||
parent?.repaint()
|
||||
}
|
||||
|
||||
private fun disposeRegs() {
|
||||
regs.forEach(Disposer::dispose)
|
||||
regs.clear()
|
||||
@RequiresEdt
|
||||
private fun disposeMd() {
|
||||
val view = md ?: return
|
||||
md = null
|
||||
codeSlot.remove(view.component)
|
||||
codeSlot.isVisible = false
|
||||
Disposer.dispose(view)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
disposeMd()
|
||||
Disposer.dispose(rules)
|
||||
}
|
||||
|
||||
private fun codeEditors(): List<EditorTextField> = mdScrolls().mapNotNull { it.viewport.view as? EditorTextField }
|
||||
|
||||
private fun mdScrolls(): List<JBScrollPane> = (md?.component as? JPanel)?.components?.filterIsInstance<JBScrollPane>() ?: emptyList()
|
||||
|
||||
private fun fence(text: String): String {
|
||||
val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0
|
||||
return "`".repeat(maxOf(3, size + 1))
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
internal fun runButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.run") }
|
||||
internal fun denyButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.deny") }
|
||||
internal fun codeLabelsForTest() = panes.toList()
|
||||
internal fun runButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.allow.once") || it.text == KiloBundle.message("session.permission.apply.allow") }
|
||||
internal fun denyButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.reject") }
|
||||
internal fun codeLabelsForTest() = codeEditors()
|
||||
internal fun diffViewsForTest() = diffViews.toList()
|
||||
internal fun headerFontForTest() = textAreas(card).first { it.font.isBold }.font
|
||||
internal fun rulesForTest() = rules
|
||||
|
||||
private fun buttons(root: Container): List<JButton> {
|
||||
val result = mutableListOf<JButton>()
|
||||
@@ -282,3 +433,380 @@ class PermissionView(
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
internal class PermissionRulesView(
|
||||
private val selection: SessionSelection?,
|
||||
private val changed: () -> Unit,
|
||||
) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Disposable {
|
||||
private val title = JBLabel(KiloBundle.message("session.permission.rules.title"))
|
||||
private val arrow = JBLabel(SessionViewIcons.chevronCollapsed)
|
||||
private val header = Stack.horizontal(gap = UiStyle.Gap.xs())
|
||||
private var box: Stack? = null
|
||||
private val rows = mutableListOf<RuleRow>()
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
init {
|
||||
header.next(arrow.align(HAlign.LEFT, VAlign.CENTER)).next(title.align(HAlign.LEFT, VAlign.CENTER)).fill(0)
|
||||
header.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
header.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
toggle()
|
||||
}
|
||||
})
|
||||
next(header)
|
||||
syncArrow()
|
||||
}
|
||||
|
||||
private var candidates = emptyList<PermissionRuleCandidate>()
|
||||
private var baseline = emptyMap<String, PermissionRuleDecision>()
|
||||
private var decisions = emptyMap<String, PermissionRuleDecision>()
|
||||
|
||||
@RequiresEdt
|
||||
fun update(candidates: List<PermissionRuleCandidate>, reset: Boolean = false) {
|
||||
isVisible = candidates.isNotEmpty()
|
||||
val old = if (reset) emptyMap() else decisions + rows.associate { it.pattern to it.decision }
|
||||
val patterns = candidates.map { it.pattern }
|
||||
val stale = this.candidates.map { it.pattern } != patterns
|
||||
this.candidates = candidates
|
||||
if (reset || stale) baseline = candidates.associate { it.pattern to it.decision }
|
||||
decisions = candidates.associate { it.pattern to (old[it.pattern] ?: it.decision) }
|
||||
if (candidates.isEmpty()) {
|
||||
box?.let {
|
||||
if (it.parent === this) remove(it)
|
||||
}
|
||||
box = null
|
||||
disposeRows()
|
||||
syncArrow()
|
||||
changed()
|
||||
return
|
||||
}
|
||||
if (stale && box != null) syncBody(rebuild = true) else syncRows()
|
||||
syncExpanded()
|
||||
syncArrow()
|
||||
changed()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun body(): Stack {
|
||||
val current = box
|
||||
if (current != null) return current
|
||||
val root = Stack.vertical(gap = UiStyle.Gap.xs())
|
||||
box = root
|
||||
syncBody(rebuild = true)
|
||||
return root
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncBody(rebuild: Boolean) {
|
||||
val root = box ?: return
|
||||
if (rebuild) {
|
||||
root.removeAll()
|
||||
disposeRows()
|
||||
for (candidate in candidates) {
|
||||
val row = RuleRow(candidate.pattern, style, selection) { pattern, decision ->
|
||||
decisions = decisions + (pattern to decision)
|
||||
syncRows()
|
||||
changed()
|
||||
}
|
||||
rows.add(row)
|
||||
root.next(row)
|
||||
}
|
||||
}
|
||||
syncRows()
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncRows() {
|
||||
for (row in rows) row.update(decisions[row.pattern] ?: PermissionRuleDecision.PENDING)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncExpanded() {
|
||||
if (box?.parent === this) return
|
||||
if (!KiloPluginSettings.getPermissionRulesExpanded()) return
|
||||
add(body())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun toggle() {
|
||||
if (candidates.isEmpty()) return
|
||||
val root = body()
|
||||
if (isExpanded()) remove(root) else add(root)
|
||||
KiloPluginSettings.setPermissionRulesExpanded(isExpanded())
|
||||
syncArrow()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun isExpanded(): Boolean = box?.parent === this
|
||||
|
||||
@RequiresEdt
|
||||
fun approved(): List<String> = candidates.map { it.pattern }.filter { decisions[it] == PermissionRuleDecision.APPROVED }
|
||||
|
||||
@RequiresEdt
|
||||
fun denied(): List<String> = candidates.map { it.pattern }.filter { decisions[it] == PermissionRuleDecision.DENIED }
|
||||
|
||||
@RequiresEdt
|
||||
fun anyDecided(): Boolean = decisions.any { baseline[it.key] != it.value }
|
||||
|
||||
@RequiresEdt
|
||||
fun setControlsEnabled(enabled: Boolean) {
|
||||
for (row in rows) row.setControlsEnabled(enabled)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
for (row in rows) row.applyStyle(style)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun approveButtonsForTest(): List<JButton> = rows.map { it.approveButtonForTest() }
|
||||
|
||||
@RequiresEdt
|
||||
fun denyButtonsForTest(): List<JButton> = rows.map { it.denyButtonForTest() }
|
||||
|
||||
@RequiresEdt
|
||||
fun commandFieldsForTest(): List<EditorTextField> = rows.map { it.commandFieldForTest() }
|
||||
|
||||
@RequiresEdt
|
||||
fun decisionForTest(pattern: String): PermissionRuleDecision = decisions[pattern] ?: PermissionRuleDecision.PENDING
|
||||
|
||||
@RequiresEdt
|
||||
fun bodyCreatedForTest(): Boolean = box != null
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncArrow() {
|
||||
arrow.icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun disposeRows() {
|
||||
for (row in rows) Disposer.dispose(row)
|
||||
rows.clear()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
disposeRows()
|
||||
}
|
||||
|
||||
private class RuleRow(
|
||||
val pattern: String,
|
||||
style: SessionEditorStyle,
|
||||
selection: SessionSelection?,
|
||||
private val changed: (String, PermissionRuleDecision) -> Unit,
|
||||
) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.xs()), Disposable {
|
||||
var decision = PermissionRuleDecision.PENDING
|
||||
private set
|
||||
|
||||
private val approve = RuleToggleButton(true) {
|
||||
changed(pattern, if (decision == PermissionRuleDecision.APPROVED) PermissionRuleDecision.PENDING else PermissionRuleDecision.APPROVED)
|
||||
}
|
||||
private val deny = RuleToggleButton(false) {
|
||||
changed(pattern, if (decision == PermissionRuleDecision.DENIED) PermissionRuleDecision.PENDING else PermissionRuleDecision.DENIED)
|
||||
}
|
||||
private val field = RuleCommandField(pattern, style, selection)
|
||||
|
||||
init {
|
||||
next(approve.align(HAlign.LEFT, VAlign.CENTER))
|
||||
next(deny.align(HAlign.LEFT, VAlign.CENTER))
|
||||
gap(UiStyle.Gap.lg())
|
||||
next(field.align(HAlign.LEFT, VAlign.CENTER))
|
||||
fill(0)
|
||||
update(PermissionRuleDecision.PENDING)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun update(value: PermissionRuleDecision) {
|
||||
decision = value
|
||||
approve.update(value == PermissionRuleDecision.APPROVED)
|
||||
deny.update(value == PermissionRuleDecision.DENIED)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setControlsEnabled(enabled: Boolean) {
|
||||
approve.isEnabled = enabled
|
||||
deny.isEnabled = enabled
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun applyStyle(style: SessionEditorStyle) {
|
||||
field.applyStyle(style)
|
||||
}
|
||||
|
||||
fun approveButtonForTest(): JButton = approve
|
||||
|
||||
fun denyButtonForTest(): JButton = deny
|
||||
|
||||
fun commandFieldForTest(): EditorTextField = field
|
||||
|
||||
override fun dispose() {
|
||||
field.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private class RuleCommandField(
|
||||
value: String,
|
||||
private var style: SessionEditorStyle,
|
||||
private val selection: SessionSelection?,
|
||||
) : EditorTextField(
|
||||
EditorFactory.getInstance().createDocument(value.trimEnd('\n')),
|
||||
ProjectManager.getInstance().defaultProject,
|
||||
PlainTextFileType.INSTANCE,
|
||||
true,
|
||||
false,
|
||||
) {
|
||||
private var reg: Disposable? = null
|
||||
|
||||
init {
|
||||
setFontInheritedFromLAF(false)
|
||||
font = style.editorFont
|
||||
addSettingsProvider(::install)
|
||||
reg = selection?.register(this)
|
||||
}
|
||||
|
||||
override fun getMaximumSize(): Dimension {
|
||||
val size = preferredSize
|
||||
return Dimension(Int.MAX_VALUE, size.height)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
font = style.editorFont
|
||||
getEditor(false)?.let(::apply)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun dispose() {
|
||||
reg?.let(Disposer::dispose)
|
||||
reg = null
|
||||
getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor)
|
||||
}
|
||||
|
||||
private fun install(ed: com.intellij.openapi.editor.Editor) {
|
||||
(ed as? EditorEx)?.let(::apply)
|
||||
}
|
||||
|
||||
private fun apply(ed: EditorEx) {
|
||||
style.applyToEditor(ed)
|
||||
ed.setBorder(JBUI.Borders.empty())
|
||||
ed.scrollPane.border = JBUI.Borders.empty()
|
||||
ed.scrollPane.viewportBorder = JBUI.Borders.empty()
|
||||
ed.backgroundColor = style.editorBackground
|
||||
ed.scrollPane.background = style.editorBackground
|
||||
ed.scrollPane.isOpaque = true
|
||||
ed.scrollPane.viewport.isOpaque = true
|
||||
ed.scrollPane.viewport.background = style.editorBackground
|
||||
ed.settings.isUseSoftWraps = false
|
||||
ed.settings.isAdditionalPageAtBottom = false
|
||||
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
syncHighlight(ed)
|
||||
}
|
||||
|
||||
private fun syncHighlight(ed: EditorEx) {
|
||||
ed.markupModel.removeAllHighlighters()
|
||||
val size = ed.document.textLength
|
||||
for (range in MdShellHighlight.command(text).ranges) {
|
||||
val start = range.start.coerceAtMost(size)
|
||||
val end = range.end.coerceAtMost(size)
|
||||
if (start >= end) continue
|
||||
ed.markupModel.addRangeHighlighter(
|
||||
range.key,
|
||||
start,
|
||||
end,
|
||||
HighlighterLayer.SYNTAX + 1,
|
||||
HighlighterTargetArea.EXACT_RANGE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class RuleToggleButton(
|
||||
private val approve: Boolean,
|
||||
private val changed: () -> Unit,
|
||||
) : JButton() {
|
||||
private var active = false
|
||||
private var over = false
|
||||
|
||||
init {
|
||||
iconButton(this)
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
addActionListener { changed() }
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) = syncOver(true)
|
||||
|
||||
override fun mouseExited(e: MouseEvent) = syncOver(false)
|
||||
})
|
||||
update(false)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension = JBUI.size(24, 24)
|
||||
|
||||
override fun getMinimumSize(): Dimension = preferredSize
|
||||
|
||||
override fun getMaximumSize(): Dimension = preferredSize
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
if (isEnabled && (active || over)) paintFill(g)
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun update(value: Boolean) {
|
||||
active = value
|
||||
icon = when {
|
||||
approve && value -> SessionViewIcons.ruleApproveActive
|
||||
approve -> SessionViewIcons.ruleApprove
|
||||
value -> SessionViewIcons.ruleDenyActive
|
||||
else -> SessionViewIcons.ruleDeny
|
||||
}
|
||||
val key = when {
|
||||
approve && value -> "session.permission.rule.approve.remove"
|
||||
approve -> "session.permission.rule.approve.add"
|
||||
value -> "session.permission.rule.deny.remove"
|
||||
else -> "session.permission.rule.deny.add"
|
||||
}
|
||||
val text = KiloBundle.message(key)
|
||||
toolTipText = text
|
||||
getAccessibleContext().accessibleName = text
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun paintFill(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
val base = UiStyle.Colors.bg()
|
||||
g2.color = when {
|
||||
active -> UiStyle.Colors.blend(base, if (approve) approvedColor() else deniedColor(), 0.15f)
|
||||
else -> UiStyle.Colors.actionHoverBackground()
|
||||
}
|
||||
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncOver(value: Boolean) {
|
||||
if (over == value) return
|
||||
over = value
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun approvedColor(): Color = JBColor.namedColor(
|
||||
"Kilo.PermissionRule.approvedForeground",
|
||||
JBColor(Color(0x1f, 0x9d, 0x66), Color(0x35, 0xd4, 0x9a)),
|
||||
)
|
||||
|
||||
private fun deniedColor(): Color = JBColor.namedColor(
|
||||
"Kilo.PermissionRule.deniedForeground",
|
||||
JBColor(Color(0xdb, 0x58, 0x66), Color(0xff, 0x6b, 0x7a)),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#1F9D66" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 193 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#35D49A" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 193 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#6C707E" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 193 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="#CED0D6" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 193 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6L14 14M14 6L6 14" stroke="#DB5866" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6L14 14M14 6L6 14" stroke="#FF6B7A" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6L14 14M14 6L6 14" stroke="#6C707E" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6L14 14M14 6L6 14" stroke="#CED0D6" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
@@ -52,6 +52,14 @@ session.permission.meta=Tool: {0} • Patterns: {1}
|
||||
session.permission.run=Run
|
||||
session.permission.allow=Allow
|
||||
session.permission.deny=Deny
|
||||
session.permission.allow.once=Allow once
|
||||
session.permission.apply.allow=Allow
|
||||
session.permission.reject=Reject
|
||||
session.permission.rules.title=Auto-approve Rules
|
||||
session.permission.rule.approve.add=Add to allowed
|
||||
session.permission.rule.approve.remove=Remove from allowed
|
||||
session.permission.rule.deny.add=Add to denied
|
||||
session.permission.rule.deny.remove=Remove from denied
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
session.permission.diff=Changes
|
||||
|
||||
@@ -360,6 +360,7 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() {
|
||||
dir,
|
||||
MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)),
|
||||
reload = {},
|
||||
refreshConfigFiles = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,7 @@ class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
assertEquals(1, rpc.permissionRulesSaved.size)
|
||||
assertEquals("perm1", rpc.permissionRulesSaved[0].first)
|
||||
assertEquals(1, rpc.permissionReplies.size)
|
||||
assertEquals(listOf("/test"), projectRpc.refreshedConfigs.toList())
|
||||
}
|
||||
|
||||
fun `test permission request maps rule decisions into model`() {
|
||||
|
||||
@@ -1037,7 +1037,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
reject = { _ -> },
|
||||
)
|
||||
val p = PermissionView(
|
||||
reply = { _, _ -> },
|
||||
reply = { _, _, _ -> },
|
||||
)
|
||||
val l = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
return SessionMessageListPanel(model, parent, q, p, l, openFile)
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
package ai.kilocode.client.session.views.permission
|
||||
|
||||
import ai.kilocode.client.plugin.KiloPluginSettings
|
||||
import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionFileDiff
|
||||
import ai.kilocode.client.session.model.PermissionMeta
|
||||
import ai.kilocode.client.session.model.PermissionRequestState
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
import ai.kilocode.client.session.model.PermissionRuleCandidate
|
||||
import ai.kilocode.client.session.model.PermissionRuleDecision
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Container
|
||||
import javax.swing.AbstractButton
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
|
||||
private val replies = mutableListOf<Triple<String, PermissionReplyDto, PermissionAlwaysRulesDto?>>()
|
||||
private lateinit var view: PermissionView
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
KiloPluginSettings.unsetPermissionRulesExpanded()
|
||||
view = PermissionView(
|
||||
reply = { id, dto -> replies.add(id to dto) },
|
||||
reply = { id, dto, rules -> replies.add(Triple(id, dto, rules)) },
|
||||
)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
view.dispose()
|
||||
KiloPluginSettings.unsetPermissionRulesExpanded()
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test run button replies once`() {
|
||||
view.show(permission())
|
||||
|
||||
@@ -37,6 +52,7 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("perm1", replies.single().first)
|
||||
assertEquals("once", replies.single().second.reply)
|
||||
assertNull(replies.single().third)
|
||||
assertFalse(view.runButtonForTest().isEnabled)
|
||||
assertFalse(view.denyButtonForTest().isEnabled)
|
||||
}
|
||||
@@ -49,6 +65,7 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("perm1", replies.single().first)
|
||||
assertEquals("reject", replies.single().second.reply)
|
||||
assertNull(replies.single().third)
|
||||
}
|
||||
|
||||
fun `test view is visible after show`() {
|
||||
@@ -99,7 +116,7 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
assertTrue("Expected no code labels for star-only patterns", view.codeLabelsForTest().isEmpty())
|
||||
}
|
||||
|
||||
fun `test bash permission shows action and command on same row`() {
|
||||
fun `test bash permission shows action and command editor`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm4",
|
||||
@@ -113,10 +130,9 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected Shell action label in text, got: $text", text.contains("Shell"))
|
||||
assertTrue("Expected command in text, got: $text", text.contains("git status --short"))
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertEquals("Expected exactly one target pane for command", 1, labels.size)
|
||||
assertTrue("Expected command in target pane, got: ${labels[0].text}", labels[0].text.contains("git status --short"))
|
||||
assertEquals("Expected exactly one command editor", 1, labels.size)
|
||||
assertTrue("Expected command in editor, got: ${labels[0].text}", labels[0].text.contains("git status --short"))
|
||||
}
|
||||
|
||||
fun `test bash permission shows only header and compact detail`() {
|
||||
@@ -134,12 +150,12 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected permission header, got: $text", text.contains("Permission required"))
|
||||
assertTrue("Expected command in text, got: $text", text.contains("git status --short"))
|
||||
assertTrue("Expected command editor text", view.codeLabelsForTest().single().text.contains("git status --short"))
|
||||
// State message should not appear for PENDING state
|
||||
assertFalse("Should not show state message for PENDING, got: $text", text.contains("Run this command?"))
|
||||
}
|
||||
|
||||
fun `test non-bash patterns show action and path as separate labels`() {
|
||||
fun `test non-bash patterns show action and path in editor`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm5",
|
||||
@@ -153,11 +169,10 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected 'Read' in text, got: $text", text.contains("Read"))
|
||||
assertTrue("Expected path in text, got: $text", text.containsPath("src/App.kt"))
|
||||
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertEquals("Expected exactly one target pane for the pattern", 1, labels.size)
|
||||
assertTrue("Expected path in target pane, got: ${labels[0].text}", labels[0].text.containsPath("src/App.kt"))
|
||||
assertEquals("Expected exactly one target editor for the pattern", 1, labels.size)
|
||||
assertTrue("Expected path in editor, got: ${labels[0].text}", labels[0].text.containsPath("src/App.kt"))
|
||||
}
|
||||
|
||||
fun `test multiple patterns joined in code label`() {
|
||||
@@ -200,8 +215,8 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
|
||||
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
|
||||
assertTrue("Should render target file in editor", view.codeLabelsForTest().single().text.containsPath("src/A.kt"))
|
||||
assertEquals("Should render target file once in labels, got: $text", 1, pathOccurrences(text, "src/A.kt"))
|
||||
// Patch markers should NOT appear — no diff content is shown
|
||||
assertFalse("Should not render patch content, got: $text", text.contains("@@"))
|
||||
assertFalse("Should not render old line, got: $text", text.contains("-old"))
|
||||
@@ -237,8 +252,8 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Should render target file once, got: $text", text.containsPath("src/A.kt"))
|
||||
assertEquals("Should not duplicate target file path, got: $text", 1, pathOccurrences(text, "src/A.kt"))
|
||||
assertTrue("Should render target file in editor", view.codeLabelsForTest().single().text.containsPath("src/A.kt"))
|
||||
assertEquals("Should render target file once in labels, got: $text", 1, pathOccurrences(text, "src/A.kt"))
|
||||
// No "unavailable" fallback text expected in new design
|
||||
assertFalse("Should not render unavailable fallback, got: $text", text.contains("unavailable"))
|
||||
val badge = view.diffViewsForTest().single().badgeForTest()
|
||||
@@ -284,7 +299,7 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
assertFalse("Should not render patch markers, got: $text", text.contains("@@"))
|
||||
}
|
||||
|
||||
fun `test no rule controls rendered`() {
|
||||
fun `test rule controls render collapsed when candidates exist`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm7",
|
||||
@@ -292,15 +307,49 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
name = "edit",
|
||||
patterns = listOf("*.kt"),
|
||||
always = listOf("src/**"),
|
||||
meta = PermissionMeta(rules = listOf("rule1")),
|
||||
meta = PermissionMeta(
|
||||
ruleDecisions = listOf(
|
||||
PermissionRuleCandidate("*.kt"),
|
||||
PermissionRuleCandidate("src/**", PermissionRuleDecision.APPROVED),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertFalse("Should not contain 'Manage Auto-Approve Rules'", text.contains("Manage Auto-Approve Rules"))
|
||||
// Only Run and Deny buttons — not extra rule toggle buttons
|
||||
val btns = buttons(view)
|
||||
assertEquals("Expected exactly 2 buttons (Run and Deny)", 2, btns.size)
|
||||
assertTrue("Should contain rules title, got: $text", text.contains("Auto-approve Rules"))
|
||||
assertFalse("Rules should be collapsed by default", view.rulesForTest().isExpanded())
|
||||
assertTrue("Rules body should be lazy", !view.rulesForTest().bodyCreatedForTest())
|
||||
|
||||
view.rulesForTest().toggle()
|
||||
|
||||
val approve = view.rulesForTest().approveButtonsForTest()
|
||||
val deny = view.rulesForTest().denyButtonsForTest()
|
||||
assertEquals(2, approve.size)
|
||||
assertEquals(2, deny.size)
|
||||
assertEquals(PermissionRuleDecision.PENDING, view.rulesForTest().decisionForTest("*.kt"))
|
||||
assertEquals(PermissionRuleDecision.APPROVED, view.rulesForTest().decisionForTest("src/**"))
|
||||
assertEquals("Add to allowed", approve[0].toolTipText)
|
||||
assertEquals("Remove from allowed", approve[1].toolTipText)
|
||||
assertEquals("Add to denied", deny[1].toolTipText)
|
||||
val commands = view.rulesForTest().commandFieldsForTest()
|
||||
assertEquals(2, commands.size)
|
||||
assertEquals("*.kt", commands[0].text)
|
||||
assertEquals("src/**", commands[1].text)
|
||||
assertEquals("Allow once", view.runButtonForTest().text)
|
||||
assertEquals("Reject", view.denyButtonForTest().text)
|
||||
assertEquals("Expected exactly 6 buttons including rule toggles", 6, buttons(view).size)
|
||||
view.runButtonForTest().doClick()
|
||||
assertNull(replies.single().third)
|
||||
}
|
||||
|
||||
fun `test no rule controls render when no candidates`() {
|
||||
view.show(permission())
|
||||
|
||||
val text = allText(view)
|
||||
assertFalse("Should not contain rules title, got: $text", text.contains("Auto-approve Rules"))
|
||||
assertFalse(view.rulesForTest().isVisible)
|
||||
assertEquals("Allow once", view.runButtonForTest().text)
|
||||
}
|
||||
|
||||
fun `test responding state disables buttons`() {
|
||||
@@ -384,6 +433,7 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("once", replies.single().second.reply)
|
||||
assertNull(replies.single().third)
|
||||
}
|
||||
|
||||
fun `test deny button uses bundle text and rejects`() {
|
||||
@@ -393,6 +443,156 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("reject", replies.single().second.reply)
|
||||
assertNull(replies.single().third)
|
||||
}
|
||||
|
||||
fun `test approved rule changes label and replies with rules`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_rules",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "git add .",
|
||||
ruleDecisions = listOf(
|
||||
PermissionRuleCandidate("git *"),
|
||||
PermissionRuleCandidate("git add *"),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("Allow once", view.runButtonForTest().text)
|
||||
view.rulesForTest().toggle()
|
||||
view.rulesForTest().approveButtonsForTest()[1].doClick()
|
||||
|
||||
assertEquals("Allow", view.runButtonForTest().text)
|
||||
view.runButtonForTest().doClick()
|
||||
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals("perm_rules", replies.single().first)
|
||||
assertEquals("once", replies.single().second.reply)
|
||||
assertEquals(listOf("git add *"), replies.single().third?.approvedAlways)
|
||||
assertEquals(emptyList<String>(), replies.single().third?.deniedAlways)
|
||||
}
|
||||
|
||||
fun `test denied rule changes label and replies with denied rules`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_deny_rules",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "git clean -fd",
|
||||
ruleDecisions = listOf(PermissionRuleCandidate("git clean *")),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
view.rulesForTest().toggle()
|
||||
view.rulesForTest().approveButtonsForTest()[0].doClick()
|
||||
assertEquals(PermissionRuleDecision.APPROVED, view.rulesForTest().decisionForTest("git clean *"))
|
||||
view.rulesForTest().denyButtonsForTest()[0].doClick()
|
||||
|
||||
assertEquals(PermissionRuleDecision.DENIED, view.rulesForTest().decisionForTest("git clean *"))
|
||||
assertEquals("Allow", view.runButtonForTest().text)
|
||||
view.runButtonForTest().doClick()
|
||||
|
||||
assertEquals(emptyList<String>(), replies.single().third?.approvedAlways)
|
||||
assertEquals(listOf("git clean *"), replies.single().third?.deniedAlways)
|
||||
}
|
||||
|
||||
fun `test reject with changed rules replies with rules`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_reject_rules",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "git push",
|
||||
ruleDecisions = listOf(PermissionRuleCandidate("git push *")),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
view.rulesForTest().toggle()
|
||||
view.rulesForTest().denyButtonsForTest()[0].doClick()
|
||||
view.denyButtonForTest().doClick()
|
||||
|
||||
assertEquals("reject", replies.single().second.reply)
|
||||
assertEquals(emptyList<String>(), replies.single().third?.approvedAlways)
|
||||
assertEquals(listOf("git push *"), replies.single().third?.deniedAlways)
|
||||
}
|
||||
|
||||
fun `test active rule toggle clears back to allow once`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_clear_rules",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "git status",
|
||||
ruleDecisions = listOf(PermissionRuleCandidate("git status")),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
view.rulesForTest().toggle()
|
||||
view.rulesForTest().approveButtonsForTest()[0].doClick()
|
||||
view.rulesForTest().approveButtonsForTest()[0].doClick()
|
||||
|
||||
assertEquals(PermissionRuleDecision.PENDING, view.rulesForTest().decisionForTest("git status"))
|
||||
assertEquals("Allow once", view.runButtonForTest().text)
|
||||
view.runButtonForTest().doClick()
|
||||
assertNull(replies.single().third)
|
||||
}
|
||||
|
||||
fun `test rules expansion persists for new view`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_persist_rules",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "pwd",
|
||||
ruleDecisions = listOf(PermissionRuleCandidate("pwd")),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
view.rulesForTest().toggle()
|
||||
assertTrue(KiloPluginSettings.getPermissionRulesExpanded())
|
||||
|
||||
val next = PermissionView(reply = { id, dto, rules -> replies.add(Triple(id, dto, rules)) })
|
||||
try {
|
||||
next.show(
|
||||
Permission(
|
||||
id = "perm_persist_rules_next",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "pwd",
|
||||
ruleDecisions = listOf(PermissionRuleCandidate("pwd")),
|
||||
),
|
||||
)
|
||||
)
|
||||
assertTrue(next.rulesForTest().isExpanded())
|
||||
assertTrue(next.rulesForTest().bodyCreatedForTest())
|
||||
} finally {
|
||||
next.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// ------ shared card shell ------
|
||||
@@ -410,10 +610,30 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
val labels = findAll<JBLabel>(view)
|
||||
assertTrue(
|
||||
"Expected permission warning icon in header",
|
||||
labels.any { it.icon == SessionViewIcons.warning },
|
||||
labels.any { it.icon == AllIcons.General.Warning },
|
||||
)
|
||||
}
|
||||
|
||||
fun `test permission description renders as content row`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_desc",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "bun test",
|
||||
raw = mapOf("description" to "Run the targeted tests"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val areas = findAll<javax.swing.text.JTextComponent>(view).filter { it.isVisible }
|
||||
assertTrue("Expected description content row", areas.any { it.text == "Run the targeted tests" && !it.font.isBold })
|
||||
assertTrue(areas.any { it.text == "Permission required" && it.font.isBold })
|
||||
}
|
||||
|
||||
// ------ button types ------
|
||||
|
||||
fun `test run button uses default style key`() {
|
||||
@@ -456,8 +676,8 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertNotNull("Should have at least one code label for command", labels.firstOrNull())
|
||||
assertEquals("Code label font family should use transcript family", style.transcriptFont.name, labels[0].font.name)
|
||||
assertEquals(style.transcriptFont.size, labels[0].font.size)
|
||||
assertEquals("Code label font family should use editor family", style.editorFont.name, labels[0].font.name)
|
||||
assertEquals(style.editorFont.size, labels[0].font.size)
|
||||
}
|
||||
|
||||
fun `test permission header uses headerFont not editor font family`() {
|
||||
@@ -480,7 +700,7 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
assertEquals("Permission header should equal headerFont", style.headerFont, header)
|
||||
}
|
||||
|
||||
fun `test code label uses code background`() {
|
||||
fun `test command editor uses editor background`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_bg",
|
||||
@@ -494,7 +714,71 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
|
||||
val labels = view.codeLabelsForTest()
|
||||
assertFalse("Expected code labels", labels.isEmpty())
|
||||
assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor(), labels[0].background)
|
||||
assertEquals(SessionEditorStyle.current().editorBackground, labels[0].background)
|
||||
}
|
||||
|
||||
fun `test command editor is retained and disposed`() {
|
||||
val base = EditorFactory.getInstance().allEditors.size
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_retain",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(command = "git status"),
|
||||
)
|
||||
)
|
||||
val editor = view.codeLabelsForTest().single()
|
||||
editor.getEditor(true)
|
||||
val count = EditorFactory.getInstance().allEditors.size
|
||||
|
||||
repeat(40) { i ->
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_retain",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(command = "echo $i"),
|
||||
state = if (i % 2 == 0) PermissionRequestState.PENDING else PermissionRequestState.RESPONDING,
|
||||
)
|
||||
)
|
||||
assertSame(editor, view.codeLabelsForTest().single())
|
||||
view.codeLabelsForTest().single().getEditor(true)
|
||||
assertEquals(count, EditorFactory.getInstance().allEditors.size)
|
||||
}
|
||||
|
||||
view.hideView()
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertTrue(view.codeLabelsForTest().isEmpty())
|
||||
assertEquals(base, EditorFactory.getInstance().allEditors.size)
|
||||
}
|
||||
|
||||
fun `test rule command field uses editor font after applyStyle`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_rule_codefont",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = emptyList(),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
command = "git log --oneline -10",
|
||||
ruleDecisions = listOf(PermissionRuleCandidate("git log *")),
|
||||
),
|
||||
)
|
||||
)
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 18)
|
||||
view.applyStyle(style)
|
||||
view.rulesForTest().toggle()
|
||||
|
||||
val field = view.rulesForTest().commandFieldsForTest().single()
|
||||
assertEquals("git log *", field.text)
|
||||
assertEquals(style.editorFont.name, field.font.name)
|
||||
assertEquals(style.editorFont.size, field.font.size)
|
||||
}
|
||||
|
||||
private fun permission() = Permission(
|
||||
@@ -515,6 +799,7 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
private fun allText(root: Container): String = buildString {
|
||||
fun collect(c: Container) {
|
||||
for (comp in c.components) {
|
||||
if (!comp.isVisible) continue
|
||||
if (comp is javax.swing.text.JTextComponent) append(comp.text).append(" ")
|
||||
if (comp is javax.swing.JLabel) append(comp.text).append(" ")
|
||||
if (comp is AbstractButton) append(comp.text).append(" ")
|
||||
|
||||
@@ -53,6 +53,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
|
||||
private set
|
||||
var globalConfigPathCalls = 0
|
||||
private set
|
||||
val refreshedConfigs = CopyOnWriteArrayList<String>()
|
||||
|
||||
override suspend fun resolveProjectDirectory(projectId: ProjectId?, hint: String): String {
|
||||
assertNotEdt("resolveProjectDirectory")
|
||||
@@ -113,6 +114,11 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
|
||||
return ConfigTargetDto(globalConfigPath, globalConfigDisplayPath, globalConfigExists)
|
||||
}
|
||||
|
||||
override suspend fun refreshConfigFiles(directory: String) {
|
||||
assertNotEdt("refreshConfigFiles")
|
||||
refreshedConfigs.add(directory)
|
||||
}
|
||||
|
||||
override suspend fun openLocalConfig(directory: String): Boolean {
|
||||
assertNotEdt("openLocalConfig")
|
||||
localConfigs.add(directory)
|
||||
|
||||
@@ -63,6 +63,9 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
|
||||
/** Resolve the editable global config target. */
|
||||
suspend fun globalConfigTarget(): ConfigTargetDto
|
||||
|
||||
/** Refresh local and global config files after external CLI writes. */
|
||||
suspend fun refreshConfigFiles(directory: String)
|
||||
|
||||
/** Open or create the local config file in the IDE. */
|
||||
suspend fun openLocalConfig(directory: String): Boolean
|
||||
|
||||
|
||||