From b4864ebd43f211fe3f594edb29798f2f5d48b599 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 15 Jun 2026 16:55:01 -0400 Subject: [PATCH] fix(jetbrains): scope session copy context --- .changeset/jetbrains-session-copy.md | 5 + .kilo/plans/jetbrains-session-copy-context.md | 85 +++++++++++++++ .../ai/kilocode/client/session/SessionUi.kt | 22 +--- .../ui/editor/SessionEditorTextField.kt | 3 + .../ui/prompt/PromptEditorTextField.kt | 4 +- .../client/session/ui/prompt/PromptPanel.kt | 11 +- .../ui/selection/SessionContextMenu.kt | 81 ++++++++++++++ .../session/ui/selection/SessionSelection.kt | 30 ++++++ .../session/views/question/QuestionView.kt | 3 +- .../session/views/tool/ShellToolView.kt | 12 ++- .../client/session/views/tool/ToolSupport.kt | 25 ++++- .../client/session/views/tool/ToolView.kt | 10 +- .../client/ui/md/hybrid/MdViewHybrid.kt | 23 +++- .../resources/kilo.jetbrains.frontend.xml | 4 + .../resources/messages/KiloBundle.properties | 1 + .../actions/HistorySessionActionsTest.kt | 2 + .../client/session/ui/PromptPanelTest.kt | 66 ++++++++++-- .../session/ui/SessionSelectionCopyTest.kt | 102 ++++++++++++++++-- .../kilocode/client/ui/md/MdViewHybridTest.kt | 59 ++++++++++ 19 files changed, 506 insertions(+), 42 deletions(-) create mode 100644 .changeset/jetbrains-session-copy.md create mode 100644 .kilo/plans/jetbrains-session-copy-context.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt diff --git a/.changeset/jetbrains-session-copy.md b/.changeset/jetbrains-session-copy.md new file mode 100644 index 00000000000..a1b74c1739d --- /dev/null +++ b/.changeset/jetbrains-session-copy.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix copying selected text from JetBrains session views. diff --git a/.kilo/plans/jetbrains-session-copy-context.md b/.kilo/plans/jetbrains-session-copy-context.md new file mode 100644 index 00000000000..a5730a888b2 --- /dev/null +++ b/.kilo/plans/jetbrains-session-copy-context.md @@ -0,0 +1,85 @@ +# JetBrains Session View Copy Context Plan + +## Goal +Make right-click Copy work for JetBrains session views without installing popup handlers on every child component. + +Use one session-level context-menu listener scoped to the session layered pane. At popup time, resolve the deepest component under the mouse inside the session UI, build the popup with that component's `DataContext`, and let the nearest view `UiDataProvider` provide `PlatformDataKeys.COPY_PROVIDER`. + +Do not add whole-session copy behavior in this pass. Copy support is view-scoped only. + +## Direction +- Keep `SessionSelection` as the single source of selected text across selectable view surfaces. +- Keep the standard IntelliJ Copy action only: ``. +- Do not install `PopupHandler.installPopupMenu(...)` on every markdown/prose/code/prompt component. +- Install one popup dispatcher for the session root/layered pane from `SessionUi.buildUi()`. +- Make session views expose copy data with `UiDataProvider`; the popup dispatcher should resolve and use the clicked component's normal IntelliJ data context. +- Remove `SessionUi` root fallback copy provider if it is only providing copy for the entire session container. View roots and editor/text children should provide copy instead. + +## Current State To Adjust +- `SessionSelection` currently owns both copy provider logic and per-component context-menu installation. +- Several components currently call `selection.installContextMenu(...)` directly. +- `SessionUi` currently implements `UiDataProvider` and delegates `COPY_PROVIDER` to `selection.provideCopy(sink)`. +- `MdViewHybrid.RootPanel`, `MdViewHybrid.CodeField`, `PromptPanel`, and `SessionEditorTextField` already expose or can expose `selection.provideCopy(sink)`. +- `PromptEditorTextField` already preserves `PromptDataKeys.SEND` through `SessionEditorTextField.uiDataSnapshot`. + +## Implementation Steps +1. Split copy data from popup installation in `SessionSelection`. + - Keep the shared `TextCopyProvider` and `provideCopy(sink)`. + - Remove or stop using `installContextMenu(component)` for per-component popup installation. + - If a helper remains in this file, make it install only once on the session root/layered pane, not on view children. + +2. Add a session-scoped popup installer. + - Preferred shape: a small helper such as `SessionContextMenu.install(root: JComponent, parent: Disposable)` under `session/ui/selection/` or `session/ui/`. + - Scope it to the `SessionRootPanel`/`LayeredOverlayPanel` created in `SessionUi.buildUi()`. + - Because normal mouse listeners on an ancestor generally do not receive mouse events targeted at descendants, use a root-scoped AWT/event-queue listener unless a quick verification confirms the layered pane receives descendant popup events. + - Filter events to `MouseEvent` popup triggers whose source component is inside the session root and whose root is showing. + - Handle both press and release popup triggers for cross-platform behavior. + - Consume the event after showing the session popup to avoid duplicate native/component popups. + +3. Resolve the clicked component at popup time. + - Convert the event point from the source component into root coordinates. + - Use Swing's deepest-component lookup from the session root coordinates, with a fallback to the source component if needed. + - Prefer the deepest visible enabled component inside `root.content`/session UI; modal blocker content may still participate if it contains session views. + - Build the `DataContext` from the resolved component via `DataManager.getInstance().getDataContext(target)`. + +4. Build the popup from the target component's data context. + - Look up `Kilo.Session.ContextMenu` with `ActionManager.getInstance().getAction(...) as? ActionGroup`. + - Use a popup API that accepts an explicit `DataContext`, such as `JBPopupFactory.createActionGroupPopup(...)`, so Copy enablement is based on the clicked view/component rather than the layered-pane root. + - Show the popup at the original mouse location, converted to the component used as the popup anchor. + - Do not introduce a custom copy action. + +5. Keep copy providers view-scoped. + - `MdViewHybrid.RootPanel` should implement `UiDataProvider` and call `selection?.provideCopy(sink)`. + - `MdViewHybrid.CodeField.uiDataSnapshot` must keep `super.uiDataSnapshot(sink)` first, then add `selection?.provideCopy(sink)`. + - `PromptPanel` and prompt editor fields should expose `selection?.provideCopy(sink)` while preserving prompt-specific send data. + - Custom question editors should receive/register the shared selection and expose the same provider through `SessionEditorTextField`. + - Remove root/session-container fallback copy exposure from `SessionUi` if it is no longer needed for a specific focused view. + +6. Remove per-component popup installs. + - Delete calls such as `selection?.installContextMenu(...)` from markdown prose panes, code fields, fallback text areas, prompt shells/editors, scroll panes, and question custom editors. + - Keep registration calls like `selection?.register(...)`; those are still needed for actual selection tracking. + +7. Keep XML and bundle entries. + - Keep `Kilo.Session.ContextMenu` in `frontend/src/main/resources/kilo.jetbrains.frontend.xml`. + - The group should contain only ``. + - Keep `action.Kilo.Session.ContextMenu.text=Session Actions` or equivalent bundle key. + +8. Update tests. + - Keep provider tests that assert view components expose `COPY_PROVIDER` directly: markdown root, code child, prompt editor, custom editor if covered. + - Adjust tests so `SessionUi` root is not the primary copy provider assertion. + - Add or update a test for single installer behavior: session root installs one popup/event dispatcher, and child components do not receive per-component popup client properties/listeners from `SessionSelection`. + - Add a focused test for target resolution if practical: given a nested view component under `SessionRootPanel`, resolving a popup point returns the nested component or its nearest useful data-provider ancestor. + - Keep descriptor coverage asserting `Kilo.Session.ContextMenu` exists and references only `$Copy`. + +9. Keep the changeset. + - Use the existing patch changeset text or keep it equivalent: `Fix copying selected text from JetBrains session views.` + +## Verification +Run from `packages/kilo-jetbrains/` after implementation: +- `./gradlew :frontend:test --tests '*SessionSelectionCopyTest' --tests '*PromptPanelTest' --tests '*MdViewHybridTest' --tests '*HistorySessionActionsTest'` +- `./gradlew typecheck` + +## Notes +- This stays in the JetBrains frontend module; no backend/RPC changes are needed. +- No session transcript serialization or whole-session copy is part of this pass. +- If the explicit-data-context popup API signature differs across platform versions, inspect IntelliJ source and use the nearest public `JBPopupFactory`/Action System API that accepts a `DataContext`. Avoid internal APIs. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 98e63cbca2e..e58a27ffcb3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -28,6 +28,7 @@ import ai.kilocode.client.session.ui.attachment.attachmentParams import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment import ai.kilocode.client.session.ui.header.SessionHeaderPanel +import ai.kilocode.client.session.ui.selection.SessionContextMenu import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget @@ -48,11 +49,6 @@ import ai.kilocode.rpc.dto.PromptPartDto import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog import com.intellij.ide.BrowserUtil -import com.intellij.ide.TextCopyProvider -import com.intellij.openapi.actionSystem.ActionUpdateThread -import com.intellij.openapi.actionSystem.DataSink -import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -97,7 +93,7 @@ class SessionUi( private val manager: SessionManager? = null, private val workspaces: KiloWorkspaceService = service(), private val migration: MigrationUiController = service(), -) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget, UiDataProvider { +) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget { companion object { private val LOG = KiloLog.create(SessionUi::class.java) @@ -170,14 +166,6 @@ class SessionUi( private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() private val selection = SessionSelection() - private val copy = object : TextCopyProvider() { - override fun getActionUpdateThread() = ActionUpdateThread.EDT - - override fun getTextLinesToCopy(): Collection? { - val text = selection.selectedText()?.takeIf { it.isNotEmpty() } ?: return null - return listOf(text) - } - } private var editorTheme = style.editorScheme private var colorTheme = UIManager.getLookAndFeel() private var disposed = false @@ -214,10 +202,6 @@ class SessionUi( internal fun currentStyle() = style - override fun uiDataSnapshot(sink: DataSink) { - sink[PlatformDataKeys.COPY_PROVIDER] = copy - } - @RequiresEdt internal fun activityKind(): SessionActivityKind? = when (val state = controller.model.state) { is SessionState.Idle, @@ -252,6 +236,7 @@ class SessionUi( private fun buildUi() { root = SessionRootPanel() + SessionContextMenu.install(root, this) migrationOverlay = MigrationOverlayPanel().apply { onSkip = { migration.skip() } @@ -327,6 +312,7 @@ class SessionUi( prompt = PromptPanel( project = project, + selection = selection, onSend = { text, files -> sendPrompt(text, files) }, onAbort = { controller.abort() }, onEnhance = controller::enhancePrompt, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index 366bc5b4801..4b9800b76ba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.editor import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.SendPromptContext +import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.project.Project @@ -24,9 +25,11 @@ import com.intellij.ui.EditorTextField internal open class SessionEditorTextField( project: Project, private val ctx: SendPromptContext? = null, + private val selection: SessionSelection? = null, ) : EditorTextField(project, PlainTextFileType.INSTANCE) { override fun uiDataSnapshot(sink: DataSink) { super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { text } ctx?.let { sink.set(PromptDataKeys.SEND, it) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt index c5aaa89b63f..3686e36be16 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt @@ -1,9 +1,11 @@ package ai.kilocode.client.session.ui.prompt import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.openapi.project.Project internal class PromptEditorTextField( project: Project, ctx: SendPromptContext, -) : SessionEditorTextField(project, ctx) + selection: SessionSelection? = null, +) : SessionEditorTextField(project, ctx, selection) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index d1fef70d22d..09e8deaa723 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton @@ -77,7 +78,8 @@ class PromptPanel( private val onSend: (String, List) -> Unit, private val onAbort: () -> Unit, private val onEnhance: (String, (Result) -> Unit) -> Unit, -) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext { + private val selection: SessionSelection? = null, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext, UiDataProvider { companion object { private val LOG = KiloLog.create(PromptPanel::class.java) @@ -114,7 +116,7 @@ class PromptPanel( private var attachment = true private var submitting = false - private val editor = PromptEditorTextField(project, this).apply { + private val editor = PromptEditorTextField(project, this, selection).apply { border = JBUI.Borders.empty() setFontInheritedFromLAF(false) setPlaceholder(placeholder()) @@ -197,6 +199,7 @@ class PromptPanel( init { applyStyle(style) + selection?.register(editor) editor.text = "" editor.addDocumentListener(object : DocumentListener { override fun documentChanged(e: DocumentEvent) { @@ -303,6 +306,10 @@ class PromptPanel( internal val defaultFocusedComponent: JComponent get() = editor + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { editor.text } + } + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt new file mode 100644 index 00000000000..7016c3a533e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt @@ -0,0 +1,81 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.ide.DataManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.AWTEvent +import java.awt.Component +import java.awt.Point +import java.awt.Toolkit +import java.awt.event.AWTEventListener +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.SwingUtilities + +internal object SessionContextMenu { + private val KEY = Any() + private const val ID = "Kilo.Session.ContextMenu" + + @RequiresEdt + fun install(root: JComponent, parent: Disposable) { + if (root.getClientProperty(KEY) == true) return + val listener = AWTEventListener { event -> + val mouse = event as? MouseEvent ?: return@AWTEventListener + if (!mouse.isPopupTrigger) return@AWTEventListener + if (mouse.id != MouseEvent.MOUSE_PRESSED && mouse.id != MouseEvent.MOUSE_RELEASED) return@AWTEventListener + show(root, mouse) + } + Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK) + root.putClientProperty(KEY, true) + Disposer.register(parent) { + Toolkit.getDefaultToolkit().removeAWTEventListener(listener) + root.putClientProperty(KEY, null) + } + } + + @RequiresEdt + internal fun target(root: JComponent, src: Component, point: Point): Component? { + if (!inside(root, src)) return null + val pt = SwingUtilities.convertPoint(src, point, root) + if (!root.contains(pt)) return null + val deep = SwingUtilities.getDeepestComponentAt(root, pt.x, pt.y)?.takeIf { inside(root, it) } ?: src + return provider(root, deep) ?: deep + } + + @RequiresEdt + private fun show(root: JComponent, event: MouseEvent) { + if (!root.isShowing) return + val src = event.component ?: return + val target = target(root, src, event.point) ?: return + val group = ActionManager.getInstance().getAction(ID) as? ActionGroup ?: return + val ctx = DataManager.getInstance().getDataContext(target) + val popup = JBPopupFactory.getInstance().createActionGroupPopup( + null, + group, + ctx, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ) + val point = SwingUtilities.convertPoint(src, event.point, target) + popup.show(RelativePoint(target, point)) + event.consume() + } + + private fun provider(root: JComponent, comp: Component): Component? { + var current: Component? = comp + while (current != null && inside(root, current)) { + if (current is UiDataProvider) return current + current = current.parent + } + return null + } + + private fun inside(root: JComponent, comp: Component): Boolean = comp === root || SwingUtilities.isDescendingFrom(comp, root) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt index 0f7fb1a14b0..ecf122555c0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt @@ -1,7 +1,11 @@ package ai.kilocode.client.session.ui.selection import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.ide.TextCopyProvider import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.event.SelectionEvent import com.intellij.openapi.editor.event.SelectionListener @@ -23,6 +27,14 @@ class SessionSelection : Disposable { private var style: SessionEditorStyle? = null private var clearing = false private var disposed = false + private val copy = object : TextCopyProvider() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun getTextLinesToCopy(): Collection? { + val text = selectedText()?.takeIf { it.isNotEmpty() } ?: return null + return listOf(text) + } + } @RequiresEdt fun selectedText(): String? { @@ -37,6 +49,24 @@ class SessionSelection : Disposable { return item.selectedText()?.takeIf { it.isNotEmpty() } } + @RequiresEdt + fun provideCopy(sink: DataSink, content: (() -> String?)? = null) { + if (content == null) { + sink.set(PlatformDataKeys.COPY_PROVIDER, copy) + return + } + sink.set(PlatformDataKeys.COPY_PROVIDER, object : TextCopyProvider() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun getTextLinesToCopy(): Collection? { + val text = selectedText()?.takeIf { it.isNotEmpty() } + ?: content()?.takeIf { it.isNotEmpty() } + ?: return null + return listOf(text) + } + }) + } + @RequiresEdt fun register(component: JTextComponent, parent: Disposable? = null): Disposable { val item = TextItem(component) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 42c61bdb25d..d4e04515aae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -488,7 +488,7 @@ class QuestionView( */ @RequiresEdt private fun buildCustomEditor(): SessionEditorTextField { - val ed = SessionEditorTextField(project) + val ed = SessionEditorTextField(project, selection = selection) ed.border = JBUI.Borders.empty() ed.setFontInheritedFromLAF(false) ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder")) @@ -506,6 +506,7 @@ class QuestionView( ex.settings.isAdditionalPageAtBottom = false ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER } + selection?.register(ed)?.let(regs::add) ed.font = style.editorFont ed.background = style.editorScheme.defaultBackground diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index 7200420d4ea..2593c941a08 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -13,6 +13,8 @@ import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField @@ -26,10 +28,10 @@ import javax.swing.ScrollPaneConstants class ShellToolView( tool: Tool, - selection: SessionSelection? = null, + private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool), private val holder: ShellHolder = ShellHolder(tool, selection), -) : SecondarySessionPartView(parts.header, { holder.body().panel }) { +) : SecondarySessionPartView(parts.header, { holder.body().panel }), UiDataProvider { override val contentId: String = tool.id @@ -43,6 +45,12 @@ class ShellToolView( sync() } + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { holder.shell?.markdown() ?: fallbackText() } + } + + private fun fallbackText() = ShellContent(item).body + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 0dd50b8932e..fa4e927396d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -16,6 +16,8 @@ import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.cli.KiloCliParser import ai.kilocode.log.KiloLog +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.PlainTextFileType @@ -164,10 +166,14 @@ class ToolBody private constructor( fun register(selection: SessionSelection, parent: Disposable) { val field = ed if (field != null) { + (field as? ToolField)?.selection = selection selection.register(field, parent) return } - area?.let { selection.register(it, parent) } + area?.let { + (it as? ToolArea)?.selection = selection + selection.register(it, parent) + } } @RequiresEdt @@ -233,7 +239,7 @@ class ToolBody private constructor( return body } - private fun area(tool: Tool, wrap: Boolean) = JBTextArea().apply { + private fun area(tool: Tool, wrap: Boolean) = ToolArea().apply { isEditable = false caret.isVisible = false caret.isSelectionVisible = true @@ -272,6 +278,14 @@ class ToolBody private constructor( } } +private class ToolArea : JBTextArea(), UiDataProvider { + var selection: SessionSelection? = null + + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { text } + } +} + private class ToolField(value: String, private var style: SessionEditorStyle) : EditorTextField( EditorFactory.getInstance().createDocument(value.trimEnd('\n')), ProjectManager.getInstance().defaultProject, @@ -279,6 +293,8 @@ private class ToolField(value: String, private var style: SessionEditorStyle) : true, false, ) { + var selection: SessionSelection? = null + init { setFontInheritedFromLAF(false) font = style.editorFont @@ -296,6 +312,11 @@ private class ToolField(value: String, private var style: SessionEditorStyle) : ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER } } + + override fun uiDataSnapshot(sink: DataSink) { + super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { text } + } } private const val SUB_CARD = "sub" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index 97324bccd2b..1f04865526f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -8,6 +8,8 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.util.Disposer import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI @@ -19,7 +21,7 @@ class ToolView( tool: Tool, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR), -) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }), UiDataProvider { override val contentId: String = tool.id @@ -34,6 +36,12 @@ class ToolView( sync() } + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { parts.content?.text ?: fallbackText() } + } + + private fun fallbackText() = listOf(commandText(), outputText()).filter { it.isNotBlank() }.joinToString("\n\n") + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index d27b2b62327..0f471a24724 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -11,6 +11,8 @@ import ai.kilocode.client.ui.md.MdView import ai.kilocode.log.KiloLog import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.markup.HighlighterLayer import com.intellij.openapi.editor.markup.HighlighterTargetArea @@ -93,7 +95,7 @@ internal open class MdViewHybrid( private var tableBorderOverride: Color? = null private var opaqueState = true - private val root = JPanel().apply { + private val root = RootPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = true background = opts().background @@ -407,7 +409,7 @@ internal open class MdViewHybrid( private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { val opts = opts() - return JBHtmlPane( + return object : JBHtmlPane( JBHtmlPaneStyleConfiguration { enableInlineCodeBackground = true enableCodeBlocksBackground = true @@ -415,7 +417,11 @@ internal open class MdViewHybrid( JBHtmlPaneConfiguration { customStyleSheetProvider { sheet() } }, - ).apply { + ), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { document.getText(0, document.length).trim() } + } + }.apply { isEditable = false isOpaque = opts.opaque background = opts.background @@ -630,6 +636,17 @@ internal open class MdViewHybrid( ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER } } + + override fun uiDataSnapshot(sink: DataSink) { + super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { text } + } + } + + private inner class RootPanel : JPanel(), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { markdown() } + } } private fun shellDisplay(term: Term, mode: Mode): ShellDisplay { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 2c8207d401e..4ad25bb87dd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -153,5 +153,9 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 9cef2cc9897..3152edbe331 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -318,6 +318,7 @@ action.Kilo.Session.Rename.description=Rename the selected session action.Kilo.Session.Delete.text=Delete action.Kilo.Session.Delete.description=Delete the selected session(s) action.Kilo.History.ContextMenu.text=History Actions +action.Kilo.Session.ContextMenu.text=Session Actions # Migration wizard migration.migrate.title=Migrate Your Settings diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index cfd34206eae..f86d9c65ff7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -414,9 +414,11 @@ class HistorySessionActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("id=\"Kilo.Session.Rename\"")) assertTrue(xml.contains("id=\"Kilo.Session.Delete\"")) assertTrue(xml.contains("id=\"Kilo.History.ContextMenu\"")) + assertTrue(xml.contains("id=\"Kilo.Session.ContextMenu\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Open\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Rename\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Delete\"")) + assertTrue(xml.contains("ref=\"${'$'}Copy\"")) } // ------ Helpers ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 0d6bb6b29b0..2cb98df966a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -11,17 +11,25 @@ import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.icons.AllIcons +import com.intellij.ide.CopyProvider import com.intellij.notification.Notification import com.intellij.notification.Notifications import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DataKey +import com.intellij.openapi.actionSystem.DataMap +import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DataSnapshotProvider +import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.actions.PasteAction +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.keymap.KeymapUtil import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -44,6 +52,7 @@ import java.io.File import java.util.Base64 import javax.imageio.ImageIO import javax.swing.JButton +import javax.swing.JPanel import javax.swing.ImageIcon import javax.swing.SwingUtilities @@ -113,6 +122,49 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(min, editor.preferredSize.height) } + fun `test prompt editor exposes selection copy provider`() { + val selection = SessionSelection() + val panel = PromptPanel(project = project, selection = selection, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val host = JPanel() + host.add(panel) + host.addNotify() + try { + editor.text = "alpha prompt" + + editor.getEditor(true)!!.selectionModel.setSelection(0, 5) + val sink = TestSink() + (editor as UiDataProvider).uiDataSnapshot(sink) + sink.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + editor.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + selection.dispose() + } + } + + fun `test prompt editor copies full content without selection`() { + val selection = SessionSelection() + val panel = PromptPanel(project = project, selection = selection, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val host = JPanel() + host.add(panel) + host.addNotify() + try { + editor.text = "alpha prompt" + + val sink = TestSink() + (editor as UiDataProvider).uiDataSnapshot(sink) + sink.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha prompt", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + editor.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + selection.dispose() + } + } + fun `test attachment only prompt can send`() { var sent = false val panel = PromptPanel(project, { text, files -> @@ -673,9 +725,11 @@ class PromptPanelTest : BasePlatformTestCase() { private class TestSink : DataSink { var send: Any? = null + var copy: CopyProvider? = null - override fun set(key: com.intellij.openapi.actionSystem.DataKey, data: T?) { + override fun set(key: DataKey, data: T?) { if (key == PromptDataKeys.SEND) send = data + if (key == PlatformDataKeys.COPY_PROVIDER) copy = data as? CopyProvider } override fun setNull(key: com.intellij.openapi.actionSystem.DataKey) { @@ -685,20 +739,20 @@ class PromptPanelTest : BasePlatformTestCase() { } override fun lazyValue( - key: com.intellij.openapi.actionSystem.DataKey, - data: (com.intellij.openapi.actionSystem.DataMap) -> T?, + key: DataKey, + data: (DataMap) -> T?, ) { } - override fun uiDataSnapshot(provider: com.intellij.openapi.actionSystem.UiDataProvider) { + override fun uiDataSnapshot(provider: UiDataProvider) { provider.uiDataSnapshot(this) } - override fun dataSnapshot(provider: com.intellij.openapi.actionSystem.DataSnapshotProvider) { + override fun dataSnapshot(provider: DataSnapshotProvider) { provider.dataSnapshot(this) } - override fun uiDataSnapshot(provider: com.intellij.openapi.actionSystem.DataProvider) { + override fun uiDataSnapshot(provider: DataProvider) { } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt index da2e4952e21..dcd6edb9995 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.ui.selection.SessionContextMenu import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.ChatEventDto @@ -15,17 +16,23 @@ import com.intellij.openapi.actionSystem.DataSnapshotProvider import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane +import java.awt.Component import java.awt.Container import java.awt.datatransfer.DataFlavor +import java.awt.Point +import javax.swing.JComponent +import javax.swing.JPanel import javax.swing.text.JTextComponent @Suppress("UnstableApiUsage") class SessionSelectionCopyTest : SessionUiTestBase() { - fun `test session ui exposes copy provider when selection exists`() { + fun `test transcript view exposes copy provider when selection exists`() { val area = showTool("alpha output") select(area, "alpha") - val provider = copyProvider() + val provider = copyProvider(area) assertNotNull(provider) assertTrue(provider!!.isCopyEnabled(DataContext.EMPTY_CONTEXT)) @@ -35,23 +42,65 @@ class SessionSelectionCopyTest : SessionUiTestBase() { val area = showTool("alpha output") select(area, "alpha") - copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) + copyProvider(area)!!.performCopy(DataContext.EMPTY_CONTEXT) assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) } + fun `test copy provider writes full component text without selection`() { + val area = showTool("alpha output") + + copyProvider(area)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha output", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + fun `test selecting another transcript component changes copied text`() { val one = showTool("alpha output", id = "tool_a") val two = showTool("bravo output", id = "tool_b") select(one, "alpha") select(two, "bravo") - copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) + copyProvider(two)!!.performCopy(DataContext.EMPTY_CONTEXT) assertTrue(one.selectedText.isNullOrEmpty()) assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) } + fun `test code block child context exposes session copy provider`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + val editor = field.getEditor(true)!! + + editor.selectionModel.setSelection(0, 5) + val provider = copyProvider(field as UiDataProvider) + provider!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test code block child copies full content without selection`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + + copyProvider(field as UiDataProvider)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha code", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test session context menu resolves deepest component`() { + val root = JPanel(null) + val mid = JPanel(null) + val child = JPanel(null) + root.setBounds(0, 0, 100, 100) + mid.setBounds(10, 10, 80, 80) + child.setBounds(5, 5, 20, 20) + root.add(mid) + mid.add(child) + + assertSame(child, SessionContextMenu.target(root, root, Point(20, 20))) + } + private fun select(area: JTextComponent, text: String) { val start = area.text.indexOf(text) assertTrue(start >= 0) @@ -79,6 +128,13 @@ class SessionSelectionCopyTest : SessionUiTestBase() { return textComponent(text) } + private fun showText(text: String) { + if (controller().id == null) showMessages() + emit(ChatEventDto.MessageUpdated("ses_test", message("msg_text"))) + emit(ChatEventDto.PartUpdated("ses_test", part("part_text", "msg_text", "text", text))) + layout() + } + private fun toolViews(root: Container): List { val out = mutableListOf() if (root is ShellToolView || root is ToolView) out.add(root) @@ -94,12 +150,46 @@ class SessionSelectionCopyTest : SessionUiTestBase() { else -> false } - private fun copyProvider(): CopyProvider? { + private fun copyProvider(provider: UiDataProvider): CopyProvider? { val sink = CopySink() - (ui as UiDataProvider).uiDataSnapshot(sink) + provider.uiDataSnapshot(sink) return sink.copy } + private fun copyProvider(component: Component): CopyProvider? { + (component as? UiDataProvider)?.let(::copyProvider)?.let { return it } + ancestors(component).filterIsInstance().firstNotNullOfOrNull(::copyProvider)?.let { return it } + val point = Point((component.width / 2).coerceAtLeast(0), (component.height / 2).coerceAtLeast(0)) + val target = SessionContextMenu.target(ui as JComponent, component, point) ?: component + ancestors(target).filterIsInstance().firstNotNullOfOrNull(::copyProvider)?.let { return it } + return providers(ui).firstNotNullOfOrNull(::copyProvider) + } + + private fun providers(root: Component): Sequence = sequence { + if (root is UiDataProvider) yield(root) + if (root is Container) { + for (child in root.components) yieldAll(providers(child)) + } + } + + private fun ancestors(component: Component): Sequence = sequence { + var comp: Component? = component + while (comp != null) { + yield(comp) + comp = comp.parent + } + } + + private fun textEditors(root: Container): List { + val out = mutableListOf() + if (root is EditorTextField) out.add(root) + for (child in root.components) { + if (child is JBScrollPane) (child.viewport.view as? EditorTextField)?.let(out::add) + if (child is Container) out.addAll(textEditors(child)) + } + return out.distinct() + } + private fun textComponent(needle: String): JTextComponent = textComponents(ui) .first { it.text.contains(needle) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 6a6a4ce3ad5..c472dda55dd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -1,9 +1,19 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.ide.CopyProvider import com.intellij.execution.process.ProcessOutputTypes import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DataKey +import com.intellij.openapi.actionSystem.DataMap +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DataSnapshotProvider +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.HighlighterColors import com.intellij.openapi.editor.colors.CodeInsightColors @@ -15,6 +25,7 @@ import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField @@ -28,6 +39,7 @@ import java.awt.Font import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import java.awt.datatransfer.DataFlavor @Suppress("UnstableApiUsage") class MdViewHybridTest : BasePlatformTestCase() { @@ -793,6 +805,33 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals("https://example.com", received.single().href) } + fun `test markdown root and code child expose selection copy provider`() { + Disposer.dispose(view) + disposed = true + val selection = SessionSelection() + val local = MdViewFactory.hybrid(selection = selection) + try { + local.set("```text\nalpha code\n```") + val field = (local.component as JPanel).components + .filterIsInstance() + .mapNotNull { it.viewport.view as? EditorTextField } + .single() + field.getEditor(true)!!.selectionModel.setSelection(0, 5) + + val root = CopySink() + (local.component as UiDataProvider).uiDataSnapshot(root) + val child = CopySink() + (field as UiDataProvider).uiDataSnapshot(child) + child.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertNotNull(root.copy) + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + Disposer.dispose(local) + selection.dispose() + } + } + private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() private fun htmls(): List = (view.component as JPanel).components.filterIsInstance() @@ -820,6 +859,26 @@ class MdViewHybridTest : BasePlatformTestCase() { UIUtil.dispatchAllInvocationEvents() } + private class CopySink : DataSink { + var copy: CopyProvider? = null + + override fun set(key: DataKey, data: T?) { + if (key == PlatformDataKeys.COPY_PROVIDER) copy = data as? CopyProvider + } + + override fun setNull(key: DataKey) {} + + override fun lazyNull(key: DataKey) {} + + override fun lazyValue(key: DataKey, data: (DataMap) -> T?) {} + + override fun uiDataSnapshot(provider: UiDataProvider) = provider.uiDataSnapshot(this) + + override fun dataSnapshot(provider: DataSnapshotProvider) = provider.dataSnapshot(this) + + override fun uiDataSnapshot(provider: DataProvider) {} + } + private fun customStyle(): SessionEditorStyle { val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme scheme.setAttributes(