diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/balloon/StringInputBalloon.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/balloon/StringInputBalloon.kt new file mode 100644 index 000000000..56020df4a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/balloon/StringInputBalloon.kt @@ -0,0 +1,152 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.kubernetes.balloon + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.ui.ComponentValidator +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.ExpirableRunnable +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.JBUI +import org.jetbrains.annotations.NotNull +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import java.util.function.BiConsumer +import java.util.function.Supplier +import javax.swing.JPanel +import javax.swing.JTextField +import kotlin.math.max + + +class StringInputBalloon(@NotNull private val onValidValue: (String) -> Unit, @NotNull private val editor: Editor) { + + companion object { + private const val MAX_WIDTH = 220.0 + } + + private var isValid = false + + fun show(event: MouseEvent) { + val (field, balloon) = create() + balloon.show(RelativePoint(event), Balloon.Position.above) + val focusManager = IdeFocusManager.getInstance(editor.project) + focusManager.doWhenFocusSettlesDown(onFocused(focusManager, field)) + } + + private fun create(): Pair { + val panel = JPanel(BorderLayout()) + val label = JBLabel("Value:") + label.border = JBUI.Borders.empty(0, 3, 0, 1) + panel.add(label, BorderLayout.WEST) + val field = JBTextField() + field.preferredSize = Dimension( + max(MAX_WIDTH, field.preferredSize.width.toDouble()).toInt(), + field.preferredSize.height + ) + panel.add(field, BorderLayout.CENTER) + val balloon = createBalloon(panel) + val disposable = Disposer.newDisposable() + Disposer.register(balloon, disposable) + ComponentValidator(disposable) + .withValidator(ValueValidator(field)) + .installOn(field) + .andRegisterOnDocumentListener(field) + .revalidate() + val keyListener = onKeyPressed(field, balloon) + field.addKeyListener(keyListener) + balloon.addListener(onClosed(field, keyListener)) + return Pair(field, balloon) + } + + private fun createBalloon(panel: JPanel): Balloon { + return JBPopupFactory.getInstance() + .createBalloonBuilder(panel) + .setCloseButtonEnabled(false) + .setBlockClicksThroughBalloon(true) + .setAnimationCycle(0) + .setHideOnKeyOutside(true) + .setHideOnClickOutside(true) + .setFillColor(panel.background) + .createBalloon() + } + + private fun onClosed(field: JBTextField, keyListener: KeyAdapter): JBPopupListener { + return object : JBPopupListener { + override fun beforeShown(event: LightweightWindowEvent) {} + override fun onClosed(event: LightweightWindowEvent) { + field.removeKeyListener(keyListener) + } + } + } + + private fun onKeyPressed(field: JTextField, balloon: Balloon) = object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + when (e.keyCode) { + KeyEvent.VK_ESCAPE -> + balloon.hide() + KeyEvent.VK_ENTER -> + if (isValid) { + balloon.hide() + onValidValue.invoke(field.text) + } + } + } + } + + private fun onFocused(focusManager: IdeFocusManager, field: JBTextField): ExpirableRunnable { + return object : ExpirableRunnable { + + override fun run() { + focusManager.requestFocus(field, true) + field.selectAll() + } + + override fun isExpired(): Boolean { + return false + } + } + } + + private inner class ValueValidator(private val field: JTextField) : Supplier { + + override fun get(): ValidationInfo? { + if (!field.isEnabled + || !field.isVisible + ) { + return null + } + return validate(field.text) + } + + private fun validate(name: String): ValidationInfo? { + val validation = if (StringUtil.isEmptyOrSpaces(name)) { + ValidationInfo("Provide a value", field).asWarning() + } else { + null + } + this@StringInputBalloon.isValid = (validation == null) + return validation + } + } + +} diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueInlayHintsProvider.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueInlayHintsProvider.kt new file mode 100644 index 000000000..751712a6d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueInlayHintsProvider.kt @@ -0,0 +1,167 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +@file:Suppress("UnstableApiUsage") + +package com.redhat.devtools.intellij.kubernetes.editor.inlay + +import com.intellij.codeInsight.hints.ChangeListener +import com.intellij.codeInsight.hints.FactoryInlayHintsCollector +import com.intellij.codeInsight.hints.ImmediateConfigurable +import com.intellij.codeInsight.hints.InlayHintsCollector +import com.intellij.codeInsight.hints.InlayHintsProvider +import com.intellij.codeInsight.hints.InlayHintsSink +import com.intellij.codeInsight.hints.NoSettings +import com.intellij.codeInsight.hints.SettingsKey +import com.intellij.codeInsight.hints.presentation.InlayPresentation +import com.intellij.codeInsight.hints.presentation.PresentationFactory +import com.intellij.json.psi.JsonProperty +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.refactoring.suggested.startOffset +import com.intellij.ui.dsl.builder.panel +import com.redhat.devtools.intellij.kubernetes.balloon.StringInputBalloon +import com.redhat.devtools.intellij.kubernetes.editor.util.decodeBase64 +import com.redhat.devtools.intellij.kubernetes.editor.util.encodeBase64 +import com.redhat.devtools.intellij.kubernetes.editor.util.getContent +import com.redhat.devtools.intellij.kubernetes.editor.util.getData +import com.redhat.devtools.intellij.kubernetes.editor.util.getKubernetesResourceInfo +import com.redhat.devtools.intellij.kubernetes.editor.util.isKubernetesResource +import com.redhat.devtools.intellij.kubernetes.editor.util.setValue +import org.jetbrains.concurrency.runAsync +import org.jetbrains.yaml.psi.YAMLKeyValue +import java.awt.event.MouseEvent +import javax.swing.JComponent + + +internal class Base64ValueInlayHintsProvider : InlayHintsProvider { + + override val key: SettingsKey = SettingsKey("KubernetesResource.hints") + override val name: String = "Kubernetes" + override val previewText: String = "Preview" + + override fun createSettings(): NoSettings { + return NoSettings() + } + + override fun createConfigurable(settings: NoSettings): ImmediateConfigurable { + return object : ImmediateConfigurable { + override fun createComponent(listener: ChangeListener): JComponent = panel {} + + override val mainCheckboxText: String = "Show hints for:" + + override val cases: List = emptyList() + } + } + + override fun getCollectorFor( + file: PsiFile, + editor: Editor, + settings: NoSettings, + sink: InlayHintsSink + ): InlayHintsCollector? { + val project = editor.project ?: return null + val virtualFile = file.virtualFile ?: return null + val info = getKubernetesResourceInfo(virtualFile, project) + if (!isKubernetesResource("Secret", info) + && !isKubernetesResource("ConfigMap", info) + ) { + return null + } + return Collector(editor) + } + + private class Collector(editor: Editor) : FactoryInlayHintsCollector(editor) { + + override fun collect(element: PsiElement, editor: Editor, sink: InlayHintsSink): Boolean { + if (!element.isValid) { + return true + } + val content = getContent(element) ?: return true + val data = getData(content) ?: return true + data.children.toList() + .forEach { child -> + val factory = Base64InlineHintFactory(child) + if (!factory.isSupported()) { + return true + } + val onClick = StringInputBalloon(onValidValue(factory::setValue, editor), editor)::show + return factory.addInlineHint(onClick, editor, sink) + } + + return true + } + + fun onValidValue(setter: (value: String, editor: Editor) -> Unit, editor: Editor): (value: String) -> Unit { + return { value -> + runAsync { + WriteCommandAction.runWriteCommandAction(editor.project) { + setter.invoke(value, editor) + } + } + } + } + } + + private class Base64InlineHintFactory(private val element: PsiElement) { + + fun isSupported(): Boolean { + return getDecoded() != null + } + + fun addInlineHint(onClick: (event: MouseEvent) -> Unit, editor: Editor, sink: InlayHintsSink): Boolean { + val offset = getValueStartOffset() ?: return true + val presentation = createPresentation(onClick, editor) ?: return true + sink.addInlineElement(offset, false, presentation, false) + return false + } + + fun setValue(value: String, editor: Editor) { + val encoded = encodeBase64(value) ?: return + setValue(encoded, element, editor.project) + } + + private fun getText(): String? { + return when (element) { + is YAMLKeyValue -> element.value?.text + is JsonProperty -> element.value?.text + else -> null + } + } + + private fun getValueStartOffset(): Int? { + return when (element) { + is YAMLKeyValue -> element.value?.startOffset + is JsonProperty -> element.value?.startOffset + else -> null + } + } + + private fun createPresentation(onClick: (event: MouseEvent) -> Unit, editor: Editor): InlayPresentation? { + val factory = PresentationFactory(editor as EditorImpl) + val decoded = getDecoded() ?: return null + val textPresentation = factory.smallText(decoded) + val hoverPresentation = factory.referenceOnHover(textPresentation) { event, translated -> + onClick.invoke(event) + } + val tooltipPresentation = factory.withTooltip("Click to change value", hoverPresentation) + val roundPresentation = factory.roundWithBackground(tooltipPresentation) + return roundPresentation + } + + private fun getDecoded(): String? { + return decodeBase64(getText()) + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/util/ResourceEditorUtils.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/util/ResourceEditorUtils.kt index 10ca4d707..fd4d11da3 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/util/ResourceEditorUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/util/ResourceEditorUtils.kt @@ -10,6 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.intellij.kubernetes.editor.util +import com.intellij.json.psi.JsonElement import com.intellij.json.psi.JsonElementGenerator import com.intellij.json.psi.JsonFile import com.intellij.json.psi.JsonProperty @@ -17,6 +18,7 @@ import com.intellij.json.psi.JsonValue import com.intellij.openapi.application.ReadAction import com.intellij.openapi.editor.Document import com.intellij.openapi.project.Project +import com.intellij.openapi.util.text.Strings import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement @@ -27,9 +29,13 @@ import org.jetbrains.yaml.YAMLElementGenerator import org.jetbrains.yaml.YAMLUtil import org.jetbrains.yaml.psi.YAMLFile import org.jetbrains.yaml.psi.YAMLKeyValue +import org.jetbrains.yaml.psi.YAMLPsiElement import org.jetbrains.yaml.psi.YAMLValue +import org.jetbrains.yaml.psi.impl.YAMLKeyValueKeyManipulator +import java.util.Base64 private const val KEY_METADATA = "metadata" +private const val KEY_DATA = "data" private const val KEY_RESOURCE_VERSION = "resourceVersion" /** @@ -59,6 +65,11 @@ fun isKubernetesResource(resourceInfo: KubernetesResourceInfo?): Boolean { && resourceInfo?.typeInfo?.kind?.isNotBlank() ?: false } +fun isKubernetesResource(kind: String, resourceInfo: KubernetesResourceInfo?): Boolean { + return resourceInfo?.typeInfo?.apiGroup?.isNotBlank() ?: false + && kind == resourceInfo?.typeInfo?.kind +} + /** * Returns [KubernetesResourceInfo] for the given file and project. Returns `null` if it could not be retrieved. * @@ -134,6 +145,13 @@ private fun createOrUpdateResourceVersion(resourceVersion: String, metadata: YAM } } +fun getContent(element: PsiElement): PsiElement? { + if (element !is PsiFile) { + return null + } + return getContent(element) +} + private fun getContent(file: PsiFile): PsiElement? { return when (file) { is YAMLFile -> { @@ -154,7 +172,7 @@ fun getMetadata(document: Document?, psi: PsiDocumentManager): PsiElement? { } val file = psi.getPsiFile(document) ?: return null val content = getContent(file) ?: return null - return getMetadata(content) ?: return null + return getMetadata(content) } private fun getMetadata(content: PsiElement): PsiElement? { @@ -172,6 +190,65 @@ private fun getMetadata(content: PsiElement): PsiElement? { } } +fun getData(element: PsiElement): PsiElement? { + return when (element) { + is YAMLPsiElement -> + element.children + .filterIsInstance(YAMLKeyValue::class.java) + .find { it.name == KEY_DATA } + ?.value + is JsonElement -> + element.children.toList() + .filterIsInstance(JsonProperty::class.java) + .find { it.name == KEY_DATA } + ?.value + else -> + null + } +} + +fun decodeBase64(value: String?): String? { + if (Strings.isEmptyOrSpaces(value)) { + return null + } + return try { + val bytes = Base64.getDecoder().decode(value) + String(bytes) + } catch (e: IllegalArgumentException) { + null + } +} + +fun encodeBase64(value: String): String? { + if (Strings.isEmptyOrSpaces(value)) { + return null + } + return try { + val bytes = Base64.getEncoder().encode(value.toByteArray()) + String(bytes) + } catch (e: IllegalArgumentException) { + null + } +} + +fun setValue(value: String, element: PsiElement, project: Project?) { + if (project == null) { + return + } + when (element) { + is YAMLKeyValue -> { + val textElement = YAMLElementGenerator.getInstance(project).createYamlKeyValue(element.keyText, value) + element.parent.addAfter(textElement, element) + element.delete() + } + is JsonProperty -> { + val textElement = JsonElementGenerator(project).createProperty(element.name, value) + element.parent.addAfter(element, textElement) + element.delete() + } + } +} + private fun getResourceVersion(metadata: YAMLKeyValue): YAMLKeyValue? { return metadata.value?.children ?.filterIsInstance(YAMLKeyValue::class.java) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f04365da9..91bcc976b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -206,6 +206,9 @@ +