diff --git a/.changeset/diagram-viewer-window-jetbrains.md b/.changeset/diagram-viewer-window-jetbrains.md new file mode 100644 index 0000000000..aa13a9437b --- /dev/null +++ b/.changeset/diagram-viewer-window-jetbrains.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Click a diagram in chat to open it in a resizable viewer window with zoom controls, trackpad pinch zoom, drag to pan and scrollbars. The diagram editor tab uses the same viewer. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloFrontendDynamicPluginListener.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloFrontendDynamicPluginListener.kt index ec2b13326e..3972766cff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloFrontendDynamicPluginListener.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloFrontendDynamicPluginListener.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.plugin import ai.kilocode.KiloPlugin import ai.kilocode.client.agentManager.worktree.unregisterWorktreeSessionEditorKind import ai.kilocode.client.session.ui.attachment.unregisterAttachmentEditorKind +import ai.kilocode.client.ui.diagram.ui.DiagramWindows import ai.kilocode.client.ui.diagram.ui.unregisterDiagramEditorKind import ai.kilocode.client.vfs.KiloEditorKindRegistry import ai.kilocode.client.vfs.KiloVirtualFileSystem @@ -30,6 +31,7 @@ object KiloFrontendUnloadCleanup { runEdt { ProjectManager.getInstance().openProjects.forEach { project -> if (project.isDisposed) return@forEach + project.getServiceIfCreated(DiagramWindows::class.java)?.closeAll() ToolWindowManager.getInstance(project).getToolWindow("Kilo Code") ?.contentManager ?.removeAllContents(true) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramCanvas.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramCanvas.kt new file mode 100644 index 0000000000..1f60018df5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramCanvas.kt @@ -0,0 +1,172 @@ +package ai.kilocode.client.ui.diagram.ui + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.diagram.Art +import ai.kilocode.client.ui.diagram.Painters +import ai.kilocode.client.ui.diagram.Palette +import com.intellij.ui.components.Magnificator +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.Point +import java.awt.Rectangle +import java.awt.RenderingHints +import javax.swing.JComponent +import javax.swing.JViewport +import javax.swing.Scrollable +import kotlin.math.roundToInt + +/** + * Scrollable diagram surface for the diagram viewer. + * + * Two states: fit (no explicit factor) tracks the viewport on both axes so the whole diagram is + * visible without scrollbars, while an explicit factor reports the scaled art as its preferred size + * so the enclosing scroll pane can scroll it. The fit scale is derived from the **viewport** extent, + * never from this component's own bounds, so sizing cannot feed back into itself. + */ +internal class DiagramCanvas(private var palette: Palette) : JComponent(), Scrollable { + private var art: Art? = null + private var factor: Double? = null + + init { + // Trackpad pinch: JBViewport reads this off its view and drives it through ZoomingDelegate, + // which does the scrolling itself from the returned point, so no anchoring here. + putClientProperty( + Magnificator.CLIENT_PROPERTY_KEY, + Magnificator { scale, at -> + zoom(this.scale() * scale) + Point((at.x * scale).roundToInt(), (at.y * scale).roundToInt()) + }, + ) + } + + @RequiresEdt + fun art(value: Art) { + art = value + revalidate() + repaint() + } + + @RequiresEdt + fun palette(value: Palette) { + palette = value + repaint() + } + + /** + * Sets an explicit scale, or restores fit when [value] is null. + * + * [at] is a point in **viewport** coordinates that should stay put across the zoom. + */ + @RequiresEdt + fun zoom(value: Double?, at: Point? = null) { + val before = scale() + factor = value?.coerceIn(MIN, maxOf(MAX, fitScale() * FIT_ZOOM)) + // Size the view up front so the viewport clamps the anchored position against the new bounds. + if (factor != null) size = preferredSize + revalidate() + repaint() + if (at != null) anchor(at, before, scale()) + } + + @RequiresEdt + fun fit() { + zoom(null) + } + + @RequiresEdt + fun scale(): Double = factor ?: fitScale() + + override fun getPreferredSize(): Dimension { + if (factor == null) return Dimension(0, 0) + val value = art ?: return Dimension(0, 0) + val size = Painters.of(value).size(value) + val scale = scale() + return Dimension( + (size.w * scale).roundToInt() + pad() * 2, + (size.h * scale).roundToInt() + pad() * 2, + ) + } + + override fun paintComponent(g: Graphics) { + background?.let { + g.color = it + g.fillRect(0, 0, width, height) + } + val value = art ?: return + val size = Painters.of(value).size(value) + val scale = scale() + val x = ((width - size.w * scale) / 2).roundToInt().coerceAtLeast(pad()) + val y = ((height - size.h * scale) / 2).roundToInt().coerceAtLeast(pad()) + paintDiagram(g, value, palette, scale, x, y) + } + + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + + override fun getScrollableUnitIncrement(visibleRect: Rectangle, orientation: Int, direction: Int) = step() + + override fun getScrollableBlockIncrement(visibleRect: Rectangle, orientation: Int, direction: Int) = step() + + override fun getScrollableTracksViewportWidth(): Boolean = tracks { extent -> preferredSize.width <= extent.width } + + override fun getScrollableTracksViewportHeight(): Boolean = tracks { extent -> preferredSize.height <= extent.height } + + private fun tracks(fits: (Dimension) -> Boolean): Boolean { + if (factor == null) return true + val viewport = parent as? JViewport ?: return false + return fits(viewport.extentSize) + } + + private fun anchor(at: Point, before: Double, after: Double) { + if (before <= 0.0) return + val viewport = parent as? JViewport ?: return + val ratio = after / before + val pos = viewport.viewPosition + val x = ((pos.x + at.x) * ratio - at.x).roundToInt() + val y = ((pos.y + at.y) * ratio - at.y).roundToInt() + viewport.viewPosition = clamped(viewport, Point(x, y)) + } + + private fun fitScale(): Double { + val value = art ?: return 1.0 + val size = Painters.of(value).size(value) + if (size.w <= 0.0 || size.h <= 0.0) return 1.0 + val extent = (parent as? JViewport)?.extentSize ?: Dimension(width, height) + val w = (extent.width - pad() * 2).coerceAtLeast(1) + val h = (extent.height - pad() * 2).coerceAtLeast(1) + return minOf(w / size.w, h / size.h).coerceAtLeast(MIN) + } + + private fun pad() = JBUI.scale(SessionUiStyle.View.Diagram.PADDING) + + private fun step() = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT) + + private companion object { + const val MIN = 0.1 + const val MAX = 4.0 + const val FIT_ZOOM = 4.0 + } +} + +/** Keeps a viewport position inside the scrollable range of its view. */ +internal fun clamped(viewport: JViewport, at: Point): Point { + val view = viewport.view ?: return at + val x = (view.width - viewport.extentSize.width).coerceAtLeast(0) + val y = (view.height - viewport.extentSize.height).coerceAtLeast(0) + return Point(at.x.coerceIn(0, x), at.y.coerceIn(0, y)) +} + +/** Paints [art] scaled by [scale] with its top-left corner at ([x], [y]). */ +internal fun paintDiagram(g: Graphics, art: Art, palette: Palette, scale: Double, x: Int, y: Int) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.translate(x, y) + g2.scale(scale, scale) + Painters.of(art).paint(g2, art, palette) + } finally { + g2.dispose() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramContent.kt new file mode 100644 index 0000000000..91d2bc99ea --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramContent.kt @@ -0,0 +1,60 @@ +package ai.kilocode.client.ui.diagram.ui + +import ai.kilocode.client.plugin.KiloBundle +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.client.ui.diagram.Out +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.colors.EditorColorsListener +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +@RequiresEdt +internal fun diagramContent(source: String, parent: Disposable): JComponent { + val root = JPanel(BorderLayout()) + val label = JBLabel().apply { + border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad()) + isVisible = false + } + val viewer = DiagramViewer(diagramPalette(SessionEditorStyle.current())) + root.add(viewer, BorderLayout.CENTER) + root.add(label, BorderLayout.SOUTH) + + fun render() { + val style = SessionEditorStyle.current() + viewer.surface(SessionUiStyle.Colors.codeBlockBackground()) + viewer.palette(diagramPalette(style)) + label.text = KiloBundle.message("diagram.rendering") + label.foreground = SessionUiStyle.Text.Secondary.foreground() + label.isVisible = true + service().render(source, diagramSpec(style), parent) { out -> + when (out) { + is Out.Ok -> { + viewer.art(out.art) + label.isVisible = false + } + + is Out.Err -> { + label.text = KiloBundle.message("diagram.error", out.message) + label.foreground = UiStyle.Colors.errorLabelForeground() + label.isVisible = true + } + } + root.revalidate() + root.repaint() + } + } + + render() + ApplicationManager.getApplication().messageBus.connect(parent) + .subscribe(EditorColorsManager.TOPIC, EditorColorsListener { ApplicationManager.getApplication().invokeLater(::render) }) + return root +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKind.kt index a2d802fb06..b885202aa3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKind.kt @@ -1,12 +1,8 @@ package ai.kilocode.client.ui.diagram.ui import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.CodeViewField -import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.codeViewScroll -import ai.kilocode.client.ui.diagram.Out import ai.kilocode.client.ui.md.hybrid.MdLanguage import ai.kilocode.client.vfs.KiloEditorKind import ai.kilocode.client.vfs.KiloEditorKindRegistry @@ -16,12 +12,9 @@ import ai.kilocode.client.vfs.KiloVirtualFile import com.intellij.ide.DataManager import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.CommonDataKeys -import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.editor.EditorFactory -import com.intellij.openapi.editor.colors.EditorColorsListener -import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer @@ -29,12 +22,9 @@ import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.Centerizer -import com.intellij.util.ui.JBUI -import java.awt.BorderLayout import java.security.MessageDigest import java.util.Collections import javax.swing.JComponent -import javax.swing.JPanel private const val TOKEN = "token" @@ -101,44 +91,7 @@ internal object DiagramEditorKind : KiloEditorKind { @RequiresEdt override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent { val text = source(file.path.params) ?: return center(KiloBundle.message("diagram.missing")) - val root = JPanel(BorderLayout()) - val label = JBLabel().apply { - border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad()) - isVisible = false - } - val panel = DiagramPanel(diagramPalette(SessionEditorStyle.current()), fit = true) - root.add(panel, BorderLayout.CENTER) - root.add(label, BorderLayout.SOUTH) - - fun render() { - val style = SessionEditorStyle.current() - panel.background = SessionUiStyle.Colors.codeBlockBackground() - panel.palette(diagramPalette(style)) - label.text = KiloBundle.message("diagram.rendering") - label.foreground = SessionUiStyle.Text.Secondary.foreground() - label.isVisible = true - service().render(text, diagramSpec(style), parent) { out -> - when (out) { - is Out.Ok -> { - panel.art(out.art) - label.isVisible = false - } - - is Out.Err -> { - label.text = KiloBundle.message("diagram.error", out.message) - label.foreground = UiStyle.Colors.errorLabelForeground() - label.isVisible = true - } - } - root.revalidate() - root.repaint() - } - } - - render() - ApplicationManager.getApplication().messageBus.connect(parent) - .subscribe(EditorColorsManager.TOPIC, EditorColorsListener { ApplicationManager.getApplication().invokeLater(::render) }) - return root + return diagramContent(text, parent) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanel.kt index a5f73c47d7..f8f6baeacc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanel.kt @@ -14,7 +14,7 @@ import java.awt.RenderingHints import javax.swing.JComponent import kotlin.math.roundToInt -internal class DiagramPanel(private var palette: Palette, private val fit: Boolean = false) : JComponent() { +internal class DiagramPanel(private var palette: Palette) : JComponent() { private var art: Art? = null private var last = Dimension(0, 0) @@ -31,19 +31,15 @@ internal class DiagramPanel(private var palette: Palette, private val fit: Boole repaint() } - override fun getPreferredSize() = if (fit) Dimension(0, 0) else fitSize() + override fun getPreferredSize() = fitSize() - override fun getMinimumSize() = if (fit) Dimension(0, 0) else fitSize() + override fun getMinimumSize() = fitSize() - override fun getMaximumSize() = if (fit) Dimension(Int.MAX_VALUE, Int.MAX_VALUE) else Dimension(Int.MAX_VALUE, fitSize().height) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, fitSize().height) override fun setBounds(x: Int, y: Int, width: Int, height: Int) { val before = fitSize() super.setBounds(x, y, width, height) - if (fit) { - repaint() - return - } if (before.height != fitSize().height) resize() } @@ -58,17 +54,9 @@ internal class DiagramPanel(private var palette: Palette, private val fit: Boole g2.dispose() } val value = art ?: return - val painter = Painters.of(value) val scale = scale(value) SessionSurface.clipped(g, width, height) { clipped -> - val inner = clipped.create() as Graphics2D - try { - inner.translate(pad(), pad()) - inner.scale(scale, scale) - painter.paint(inner, value, palette) - } finally { - inner.dispose() - } + paintDiagram(clipped, value, palette, scale, pad(), pad()) } } @@ -90,7 +78,7 @@ internal class DiagramPanel(private var palette: Palette, private val fit: Boole val size = Painters.of(value).size(value) val avail = (width.takeIf { it > 0 } ?: parent?.width ?: 0) - pad() * 2 val byWidth = if (avail > 0) minOf(1.0, avail / size.w) else 1.0 - val max = if (fit) height - pad() * 2 else JBUI.scale(SessionUiStyle.View.Diagram.MAX_HEIGHT) - pad() * 2 + val max = JBUI.scale(SessionUiStyle.View.Diagram.MAX_HEIGHT) - pad() * 2 val byHeight = if (size.h > 0.0) minOf(1.0, max / size.h) else 1.0 return minOf(byWidth, byHeight).coerceAtLeast(0.1) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramViewer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramViewer.kt new file mode 100644 index 0000000000..5b120b4530 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramViewer.kt @@ -0,0 +1,159 @@ +package ai.kilocode.client.ui.diagram.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ToolbarButtonAction +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.diagram.Art +import ai.kilocode.client.ui.diagram.Palette +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.toolbarButton +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLayeredPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Cursor +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseWheelEvent +import java.awt.event.MouseWheelListener +import javax.swing.Icon +import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities + +/** + * Reusable zoomable diagram surface: a scrollable [DiagramCanvas] with floating zoom controls. + * + * Shared by the diagram editor tab and the detached diagram window. Zoom comes from three sources: + * trackpad pinch (via the canvas [com.intellij.ui.components.Magnificator]), Ctrl/Cmd + wheel, and + * the overlay buttons. Dragging pans whenever the scaled diagram overflows the viewport. + */ +internal class DiagramViewer(palette: Palette) : JBLayeredPane() { + private val canvas = DiagramCanvas(palette) + private val scroll = JBScrollPane(canvas).apply { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + // Built by chaining rather than `apply`, so these lambdas cannot bind to Stack's own fit(). + private val controls = Stack.horizontal(UiStyle.Gap.xs()) + .next(control(AllIcons.General.ZoomIn, "diagram.zoom.in") { zoomIn() }) + .next(control(AllIcons.General.ZoomOut, "diagram.zoom.out") { zoomOut() }) + .next(control(AllIcons.General.FitContent, "diagram.zoom.fit") { fit() }) + private val wheel = Wheel() + private val drag = Drag() + + init { + // Layer first, then add: add(Component, Int) binds to Container.add(comp, index) from Kotlin, + // so the layer would be taken as an insertion index and both children would end up in the + // default layer, with the scroll pane painting over the controls and swallowing their clicks. + setLayer(scroll, DEFAULT_LAYER) + setLayer(controls, PALETTE_LAYER) + add(scroll) + add(controls) + scroll.addMouseWheelListener(wheel) + canvas.addMouseListener(drag) + canvas.addMouseMotionListener(drag) + } + + @RequiresEdt + fun art(value: Art) { + canvas.art(value) + } + + @RequiresEdt + fun palette(value: Palette) { + canvas.palette(value) + } + + /** Paints the diagram surface (canvas and viewport) with [color]. */ + @RequiresEdt + fun surface(color: Color) { + background = color + scroll.background = color + scroll.viewport.background = color + canvas.background = color + } + + @RequiresEdt + fun zoomIn(at: Point? = null) { + canvas.zoom(canvas.scale() * STEP, at) + } + + @RequiresEdt + fun zoomOut(at: Point? = null) { + canvas.zoom(canvas.scale() / STEP, at) + } + + @RequiresEdt + fun fit() { + canvas.fit() + } + + override fun doLayout() { + scroll.setBounds(0, 0, width, height) + val size = controls.preferredSize + controls.setBounds(width - size.width - UiStyle.Gap.pad(), UiStyle.Gap.pad(), size.width, size.height) + controls.doLayout() + } + + private inner class Wheel : MouseWheelListener { + override fun mouseWheelMoved(e: MouseWheelEvent) { + if (!e.isControlDown && !e.isMetaDown) return + val at = SwingUtilities.convertPoint(e.component, e.point, scroll.viewport) + if (e.wheelRotation < 0) zoomIn(at) + if (e.wheelRotation > 0) zoomOut(at) + e.consume() + } + } + + private inner class Drag : MouseAdapter() { + private var from: Point? = null + private var origin: Point? = null + + override fun mousePressed(e: MouseEvent) { + if (e.button != MouseEvent.BUTTON1 || !overflows()) return + from = e.point + origin = scroll.viewport.viewPosition + canvas.cursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR) + } + + override fun mouseDragged(e: MouseEvent) { + val start = from ?: return + val base = origin ?: return + val at = Point(base.x + start.x - e.x, base.y + start.y - e.y) + scroll.viewport.viewPosition = clamped(scroll.viewport, at) + } + + override fun mouseReleased(e: MouseEvent) { + release() + } + + override fun mouseExited(e: MouseEvent) { + release() + } + + private fun release() { + if (from == null) return + from = null + origin = null + canvas.cursor = Cursor.getDefaultCursor() + } + + private fun overflows(): Boolean { + val viewport = scroll.viewport + val view = viewport.view ?: return false + return view.width > viewport.extentSize.width || view.height > viewport.extentSize.height + } + } + + private companion object { + const val STEP = 1.25 + + fun control(icon: Icon, key: String, handler: () -> Unit) = + toolbarButton(ToolbarButtonAction(icon, KiloBundle.message(key), handler), fill = true) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramWindow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramWindow.kt new file mode 100644 index 0000000000..34434ab5ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramWindow.kt @@ -0,0 +1,145 @@ +package ai.kilocode.client.ui.diagram.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.telemetry.Telemetry +import com.intellij.ide.DataManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.FrameWrapper +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.WindowState +import com.intellij.openapi.wm.WindowManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Rectangle +import java.util.function.BooleanSupplier +import javax.swing.JComponent +import javax.swing.RootPaneContainer + +private const val DIMENSION_KEY = "ai.kilocode.DiagramViewer" +private const val SHARE = 0.75 + +internal fun diagramWindowBounds(frame: Rectangle): Rectangle { + val w = (frame.width * SHARE).toInt().coerceAtLeast(1) + val h = (frame.height * SHARE).toInt().coerceAtLeast(1) + val x = frame.x + (frame.width - w) / 2 + val y = frame.y + (frame.height - h) / 2 + return Rectangle(x, y, w, h) +} + +internal interface DiagramHandle : Disposable { + fun show() + fun focus() +} + +@Service(Service.Level.PROJECT) +internal class DiagramWindows internal constructor( + project: Project, + private val factory: (String) -> DiagramHandle, + private val send: (String, Map) -> Unit, +) { + constructor(project: Project) : this(project, { source -> FrameHandle(project, source) }, Telemetry::send) + + private val windows = mutableMapOf() + + @RequiresEdt + fun open(source: String): Boolean { + val token = service().put(source) + val handle = windows[token] + if (handle != null) { + handle.focus() + track(true) + return true + } + val next = factory(source) + windows[token] = next + Disposer.register(next) { + if (windows[token] === next) windows.remove(token) + } + next.show() + track(false) + return true + } + + @RequiresEdt + fun closeAll() { + val all = windows.values.toList() + windows.clear() + all.forEach(Disposer::dispose) + } + + private fun track(reused: Boolean) { + send( + "Diagram Viewer Opened", + mapOf( + "surface" to "session", + "reused" to reused.toString(), + ), + ) + } +} + +private class FrameHandle(project: Project, source: String) : DiagramHandle { + private val frame = DiagramFrame(project).apply { + component = diagramContent(source, this) + preferredFocusedComponent = component + closeOnEsc() + setOnCloseHandler(BooleanSupplier { + Disposer.dispose(this@FrameHandle) + false + }) + } + + override fun show() { + frame.show(true) + } + + override fun focus() { + val window = frame.getFrame() + window.toFront() + window.requestFocus() + } + + override fun dispose() { + if (!frame.isDisposed) Disposer.dispose(frame) + } +} + +/** + * A frame rather than a dialog on purpose. + * + * The viewer is a document surface, so it belongs in the Window menu and should live on its own + * instead of floating over the IDE frame. It also keeps the window closer to the editor tab, which + * matters for trackpad zoom: magnification is routed per window by the platform's + * [com.intellij.openapi.actionSystem.impl.MouseGestureManager], and the editor tab (a plain IDE + * frame) is the surface where that routing is known to reach our canvas. + */ +private class DiagramFrame(private val project: Project) : FrameWrapper( + project, + DIMENSION_KEY, + false, + KiloBundle.message("diagram.title"), +) { + override fun loadFrameState(state: WindowState?) { + if (state != null) { + super.loadFrameState(state) + return + } + val base = WindowManager.getInstance().getFrame(project)?.bounds + if (base == null) { + super.loadFrameState(null) + return + } + getFrame().bounds = diagramWindowBounds(base) + (getFrame() as RootPaneContainer).rootPane.revalidate() + } +} + +@RequiresEdt +internal fun openDiagramWindow(anchor: JComponent, source: String): Boolean { + val ctx = DataManager.getInstance().getDataContext(anchor) + val project = CommonDataKeys.PROJECT.getData(ctx) ?: return false + return project.service().open(source) +} 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 a39a634e96..6b77d23a81 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 @@ -13,6 +13,7 @@ import ai.kilocode.client.ui.diagram.ui.DiagramPanel import ai.kilocode.client.ui.diagram.ui.Diagrams import ai.kilocode.client.ui.diagram.ui.diagramPalette import ai.kilocode.client.ui.diagram.ui.diagramSpec +import ai.kilocode.client.ui.diagram.ui.openDiagramWindow import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockFactory @@ -43,6 +44,7 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Component +import java.awt.Cursor import java.awt.Dimension import java.awt.Font import java.awt.Graphics @@ -50,6 +52,7 @@ import java.awt.Graphics2D import java.awt.Point import java.awt.RenderingHints import java.awt.event.HierarchyEvent +import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.Box import javax.swing.BoxLayout @@ -972,10 +975,21 @@ internal open class MdViewHybrid( private var hash = 0 private var gen = 0 private var font = spec().font + private val click = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.button != MouseEvent.BUTTON1 || e.clickCount != 1) return + if (!panel.isVisible) return + openDiagramWindow(panel, (this@DiagramView.desc as Desc.Code).text) + } + } init { panel.background = opts().preBg panel.isVisible = false + panel.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + panel.toolTipText = KiloBundle.message("diagram.viewer.hint") + panel.addMouseListener(click) + Disposer.register(disposable) { panel.removeMouseListener(click) } root.next(panel).next(codePane).next(label) root.text = { (this.desc as Desc.Code).text } kick() 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 98f86d7773..eeb37fb887 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -12,6 +12,10 @@ diagram.missing=Diagram source is no longer available. diagram.open=Open in Editor diagram.path=Kilo / Diagrams / {0} diagram.rendering=Rendering diagram... +diagram.viewer.hint=Open diagram viewer +diagram.zoom.fit=Fit to Window +diagram.zoom.in=Zoom In +diagram.zoom.out=Zoom Out session.action.cancel=Cancel session.connection.connecting=Loading... diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKindTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKindTest.kt index bbd0fde01e..df327b8410 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKindTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramEditorKindTest.kt @@ -105,7 +105,8 @@ class DiagramEditorKindTest : BasePlatformTestCase() { try { assertEquals(KiloBundle.message("diagram.title"), main.name) assertEquals(KiloBundle.message("diagram.source"), source.name) - assertNotNull(main.component) + // The tab hosts the same zoomable viewer the diagram window uses. + assertEquals(1, descendants(main.component).filterIsInstance().size) val field = descendants(source.component).filterIsInstance().single() assertEquals(flow.trim(), field.text.trim()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanelTest.kt index 13f856b6c4..ef4e097465 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramPanelTest.kt @@ -32,17 +32,6 @@ class DiagramPanelTest { assertTrue(panel.preferredSize.height <= 520) } - @Test - fun `test fit mode scales to the component bounds instead of the transcript cap`() { - val panel = DiagramPanel(palette(), fit = true) - panel.setSize(1_000, 1_000) - panel.art(scene(100.0, 2_000.0)) - - // The transcript cap (480) no longer applies; the panel fills whatever the tab gives it. - assertEquals(0, panel.preferredSize.height) - assertTrue(panel.maximumSize.height > 520) - } - @Test fun `test block copies fence text and offers copy plus open in editor`() { val block = DiagramBlock() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramViewerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramViewerTest.kt new file mode 100644 index 0000000000..bff4a11cdc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramViewerTest.kt @@ -0,0 +1,294 @@ +package ai.kilocode.client.ui.diagram.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Palette +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Type +import ai.kilocode.client.util.edtWait +import com.intellij.icons.AllIcons +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.Magnificator +import java.awt.Color +import java.awt.Container +import java.awt.Font +import java.awt.Point +import java.awt.event.MouseEvent +import java.awt.event.MouseWheelEvent +import javax.swing.AbstractButton +import javax.swing.JComponent +import javax.swing.JViewport +import javax.swing.SwingUtilities + +class DiagramViewerTest : BasePlatformTestCase() { + fun `test fit tracks the viewport and needs no scrolling`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + + assertTrue(scale(viewer) < 1.0) + val canvas = canvas(viewer) + assertTrue(canvas.getScrollableTracksViewportWidth()) + assertTrue(canvas.getScrollableTracksViewportHeight()) + } + + fun `test fit upscales a diagram smaller than the viewport`() = edtWait { + val viewer = viewer(800, 600) + viewer.art(scene(100.0, 100.0)) + layout(viewer) + + assertTrue("fit should fill the window, not cap at native size", scale(viewer) > 1.0) + } + + fun `test zooming in leaves fit and lets the scroll pane scroll`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + val fit = scale(viewer) + + viewer.zoomIn() + layout(viewer) + + assertEquals(fit * 1.25, scale(viewer), 1e-6) + val canvas = canvas(viewer) + assertTrue(canvas.preferredSize.width > 0) + assertFalse(canvas.getScrollableTracksViewportWidth()) + } + + fun `test zoom out then fit restores viewport tracking`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + val fit = scale(viewer) + + viewer.zoomOut() + assertEquals(fit / 1.25, scale(viewer), 1e-6) + + viewer.fit() + layout(viewer) + + assertEquals(fit, scale(viewer), 1e-6) + assertEquals(0, canvas(viewer).preferredSize.width) + } + + fun `test fit refits after the viewport is resized`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + val narrow = scale(viewer) + + viewer.setSize(800, 600) + layout(viewer) + + assertTrue(scale(viewer) > narrow) + } + + fun `test zoom clamps to the supported range`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(400.0, 300.0)) + layout(viewer) + + repeat(30) { viewer.zoomIn() } + assertEquals(4.0, scale(viewer), 1e-6) + + repeat(60) { viewer.zoomOut() } + assertEquals(0.1, scale(viewer), 1e-6) + } + + fun `test canvas exposes a magnificator that scales and reports the anchor`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(400.0, 300.0)) + layout(viewer) + val canvas = canvas(viewer) + val magnificator = canvas.getClientProperty(Magnificator.CLIENT_PROPERTY_KEY) as Magnificator + val before = scale(viewer) + + val at = magnificator.magnify(2.0, Point(30, 40)) + + assertEquals(before * 2.0, scale(viewer), 1e-6) + assertEquals(Point(60, 80), at) + } + + fun `test control wheel zooms and consumes while a plain wheel scrolls`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + val fit = scale(viewer) + + val plain = wheel(viewer, control = false, rotation = -1) + viewport(viewer).parent.dispatchEvent(plain) + + assertFalse(plain.isConsumed) + assertEquals(fit, scale(viewer), 1e-6) + + val zoom = wheel(viewer, control = true, rotation = -1) + viewport(viewer).parent.dispatchEvent(zoom) + + assertTrue(zoom.isConsumed) + assertEquals(fit * 1.25, scale(viewer), 1e-6) + } + + fun `test dragging pans the viewport and clamps at the edges`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + repeat(4) { viewer.zoomIn() } + layout(viewer) + val canvas = canvas(viewer) + + canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_PRESSED, 200, 150)) + canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_DRAGGED, 150, 120)) + + assertEquals(Point(50, 30), viewport(viewer).viewPosition) + + canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_DRAGGED, 400, 350)) + canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_RELEASED, 400, 350)) + + assertEquals(Point(0, 0), viewport(viewer).viewPosition) + } + + fun `test overlay offers zoom in zoom out and fit`() = edtWait { + val viewer = viewer(400, 300) + + val buttons = buttons(viewer) + + assertEquals(3, buttons.size) + assertEquals( + listOf(AllIcons.General.ZoomIn, AllIcons.General.ZoomOut, AllIcons.General.FitContent), + buttons.map { it.icon }, + ) + assertEquals( + listOf( + KiloBundle.message("diagram.zoom.in"), + KiloBundle.message("diagram.zoom.out"), + KiloBundle.message("diagram.zoom.fit"), + ), + buttons.map { it.toolTipText }, + ) + } + + fun `test overlay floats above the scroll pane and receives its own clicks`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + val zoom = buttons(viewer).first() + + val at = SwingUtilities.convertPoint(zoom, zoom.width / 2, zoom.height / 2, viewer) + + assertSame("the scroll pane must not cover the controls", zoom, SwingUtilities.getDeepestComponentAt(viewer, at.x, at.y)) + assertFalse("overlapping layers cannot use optimized drawing", viewer.isOptimizedDrawingEnabled) + } + + fun `test overlay buttons drive the zoom`() = edtWait { + val viewer = viewer(400, 300) + viewer.art(scene(2_000.0, 1_000.0)) + layout(viewer) + val fit = scale(viewer) + val buttons = buttons(viewer) + + buttons[0].doClick() + assertEquals(fit * 1.25, scale(viewer), 1e-6) + + buttons[1].doClick() + assertEquals(fit, scale(viewer), 1e-6) + + buttons[0].doClick() + buttons[2].doClick() + assertEquals(fit, scale(viewer), 1e-6) + assertEquals(0, canvas(viewer).preferredSize.width) + } + + private fun viewer(width: Int, height: Int) = DiagramViewer(palette()).apply { + setSize(width, height) + surface(Color.WHITE) + } + + private fun layout(viewer: DiagramViewer) { + viewer.doLayout() + layout(viewer as Container) + } + + private fun layout(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach(::layout) + } + + private fun viewport(viewer: DiagramViewer): JViewport = descendants(viewer) + .filterIsInstance() + .single() + .viewport + + private fun canvas(viewer: DiagramViewer) = viewport(viewer).view as DiagramCanvas + + private fun scale(viewer: DiagramViewer) = canvas(viewer).scale() + + private fun wheel(viewer: DiagramViewer, control: Boolean, rotation: Int): MouseWheelEvent { + val scroll = viewport(viewer).parent + return MouseWheelEvent( + scroll, + MouseEvent.MOUSE_WHEEL, + System.currentTimeMillis(), + if (control) MouseEvent.CTRL_DOWN_MASK else 0, + 10, + 10, + 0, + false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, + 1, + rotation, + ) + } + + private fun mouse(target: JComponent, id: Int, x: Int, y: Int) = MouseEvent( + target, + id, + System.currentTimeMillis(), + MouseEvent.BUTTON1_DOWN_MASK, + x, + y, + 1, + false, + MouseEvent.BUTTON1, + ) + + private fun buttons(root: Container): List { + val out = mutableListOf() + for (comp in root.components) { + if (comp is AbstractButton) out.add(comp) + if (comp is Container) out.addAll(buttons(comp)) + } + return out + } + + private fun descendants(root: Container): List { + val out = mutableListOf() + for (comp in root.components) { + out.add(comp) + if (comp is Container) out.addAll(descendants(comp)) + } + return out + } + + private fun scene(w: Double, h: Double) = Scene( + Type.Flowchart, + listOf(Mark.Box(Rect(0.0, 0.0, w, h), 4.0, Role.Surface, Role.Border)), + Size(w, h), + ) + + private fun palette() = Palette( + surface = Color.WHITE, + border = Color.BLACK, + text = Color.BLACK, + muted = Color.GRAY, + accent = Color.BLUE, + note = Color.YELLOW, + cluster = Color.LIGHT_GRAY, + line = Color.DARK_GRAY, + font = Font(Font.SANS_SERIF, Font.PLAIN, 12), + bold = Font(Font.SANS_SERIF, Font.BOLD, 12), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramWindowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramWindowTest.kt new file mode 100644 index 0000000000..16e168fda3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramWindowTest.kt @@ -0,0 +1,111 @@ +package ai.kilocode.client.ui.diagram.ui + +import ai.kilocode.client.util.edtWait +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Rectangle + +/** + * Covers the window bookkeeping without opening a real window: [DiagramWindows] takes its handle + * factory as a dependency, so the reuse, dispose and telemetry paths are exercised against fakes + * while the [com.intellij.openapi.ui.FrameWrapper] wiring stays out of the test. + */ +class DiagramWindowTest : BasePlatformTestCase() { + private val events = mutableListOf>>() + private val handles = mutableListOf() + + fun `test bounds take three quarters of the frame and stay centred`() { + val bounds = diagramWindowBounds(Rectangle(100, 50, 1000, 800)) + + assertEquals(Rectangle(225, 150, 750, 600), bounds) + } + + fun `test bounds survive a degenerate frame`() { + val bounds = diagramWindowBounds(Rectangle(0, 0, 1, 1)) + + assertEquals(Rectangle(0, 0, 1, 1), bounds) + } + + fun `test the same source reuses one window and a different source opens another`() = edtWait { + val windows = windows() + + assertTrue(windows.open("flowchart TD\nA-->B")) + + assertEquals(1, handles.size) + assertEquals(1, handles.single().shown) + assertEquals(0, handles.single().focused) + + assertTrue(windows.open("flowchart TD\nA-->B")) + + assertEquals(1, handles.size) + assertEquals(1, handles.single().shown) + assertEquals(1, handles.single().focused) + + assertTrue(windows.open("flowchart TD\nA-->C")) + + assertEquals(2, handles.size) + assertEquals(listOf(1, 1), handles.map { it.shown }) + } + + fun `test disposing a window drops it so the next click opens a fresh one`() = edtWait { + val windows = windows() + windows.open("flowchart TD\nA-->B") + + Disposer.dispose(handles.single()) + windows.open("flowchart TD\nA-->B") + + assertEquals(2, handles.size) + assertEquals(0, handles.last().focused) + assertEquals(1, handles.last().shown) + } + + fun `test closeAll disposes every open window`() = edtWait { + val windows = windows() + windows.open("flowchart TD\nA-->B") + windows.open("flowchart TD\nA-->C") + + windows.closeAll() + windows.open("flowchart TD\nA-->B") + + assertEquals(listOf(1, 1, 0), handles.map { it.disposed }) + assertEquals(3, handles.size) + } + + fun `test opening reports whether the window was reused`() = edtWait { + val windows = windows() + + windows.open("flowchart TD\nA-->B") + windows.open("flowchart TD\nA-->B") + + assertEquals(listOf("Diagram Viewer Opened", "Diagram Viewer Opened"), events.map { it.first }) + assertEquals(listOf("false", "true"), events.map { it.second["reused"] }) + assertEquals(listOf("session", "session"), events.map { it.second["surface"] }) + } + + private fun windows() = DiagramWindows( + project, + { FakeHandle().also(handles::add) }, + { event, props -> events.add(event to props) }, + ) + + private class FakeHandle : DiagramHandle { + var shown = 0 + private set + var focused = 0 + private set + var disposed = 0 + private set + + override fun show() { + shown++ + } + + override fun focus() { + focused++ + } + + override fun dispose() { + disposed++ + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt index 0fe4900f9d..5fb234a021 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt @@ -13,15 +13,24 @@ import ai.kilocode.client.ui.diagram.Size import ai.kilocode.client.ui.diagram.Spec import ai.kilocode.client.ui.diagram.Type import ai.kilocode.client.ui.diagram.ui.DiagramBlock +import ai.kilocode.client.ui.diagram.ui.DiagramHandle import ai.kilocode.client.ui.diagram.ui.DiagramPanel +import ai.kilocode.client.ui.diagram.ui.DiagramWindows import ai.kilocode.client.ui.diagram.ui.Diagrams +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService import com.intellij.util.ui.UIUtil +import java.awt.Cursor import java.awt.Point +import java.awt.event.MouseEvent +import javax.swing.JComponent import javax.swing.JPanel @Suppress("UnstableApiUsage") @@ -81,6 +90,34 @@ class MdViewDiagramTest : BasePlatformTestCase() { assertSame(block().copyToolbar, (target as SessionCopyTarget).copyToolbar) } + fun `test clicking a rendered diagram opens the viewer window`() { + val opened = windows() + view.set("```mermaid\nflowchart TD\nA-->B\n```") + drain() + attach() + + click(diagram()) + + assertEquals(listOf("flowchart TD\nA-->B\n"), opened) + assertEquals(Cursor.HAND_CURSOR, diagram().cursor.type) + } + + fun `test the streaming source fallback is not a viewer trigger`() { + // Only the rendered diagram opens the window, so the source pane shown while a fence streams + // (and after an engine error) keeps its plain text behaviour. + val opened = windows() + view.append("```mermaid\nflowchart TD\n") + drain() + attach() + + click(codePane() as JComponent) + click(diagram()) + + assertTrue(codePane().isVisible) + assertFalse(diagram().isVisible) + assertTrue(opened.isEmpty()) + } + fun `test engine error keeps source visible`() { engine.out = Out.Err(ai.kilocode.client.ui.diagram.Fault.Syntax, "bad syntax") @@ -134,6 +171,38 @@ class MdViewDiagramTest : BasePlatformTestCase() { private fun drain() = coroutines.drain() + /** Records the sources the transcript hands to the viewer window instead of opening one. */ + private fun windows(): List { + val opened = mutableListOf() + val service = DiagramWindows(project, { source -> opened.add(source); NoopHandle() }, { _, _ -> }) + project.replaceService(DiagramWindows::class.java, service, testRootDisposable) + return opened + } + + /** Puts the transcript under a project data provider so the click can resolve the project. */ + private fun attach() { + val panel = DataPanel(project) + panel.add(root()) + panel.setSize(400, 400) + panel.doLayout() + } + + private fun click(target: JComponent) { + target.dispatchEvent( + MouseEvent( + target, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + 1, + 1, + 1, + false, + MouseEvent.BUTTON1, + ), + ) + } + private fun root() = view.component as JPanel private fun block() = descendants(root()).filterIsInstance().single() @@ -155,6 +224,20 @@ class MdViewDiagramTest : BasePlatformTestCase() { return out } + private class DataPanel(private val project: Project) : JPanel(), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + sink[CommonDataKeys.PROJECT] = project + } + } + + private class NoopHandle : DiagramHandle { + override fun show() = Unit + + override fun focus() = Unit + + override fun dispose() = Unit + } + private class FakeEngine : Engine { var calls = 0 var out: Out? = null