diff --git a/.github/workflows/IJ.yml b/.github/workflows/IJ.yml index 13d59c503..4aaf63fee 100644 --- a/.github/workflows/IJ.yml +++ b/.github/workflows/IJ.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - IJ: [IC-2021.1, IC-2021.2, IC-2021.3, IC-2022.1, IC-2022.2, IC-2022.3, IC-2023.1, IC-2023.2, IC-2023.3] + IJ: [IC-2022.1, IC-2022.2, IC-2022.3, IC-2023.1, IC-2023.2, IC-2023.3] steps: - uses: actions/checkout@v2 diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/SwingUtils.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/SwingUtils.kt new file mode 100644 index 000000000..d1bbb6430 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/SwingUtils.kt @@ -0,0 +1,30 @@ +/******************************************************************************* + * 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 + +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import javax.swing.text.JTextComponent + +fun createExplanationLabel(text: String): JBLabel { + return JBLabel(text).apply { + componentStyle = UIUtil.ComponentStyle.SMALL + foreground = JBUI.CurrentTheme.Link.Foreground.DISABLED + } +} + +fun insertNewLineAtCaret(textComponent: JTextComponent) { + val caretPosition = textComponent.caretPosition + val newText = StringBuilder(textComponent.text).insert(caretPosition, '\n').toString() + textComponent.text = newText + textComponent.caretPosition = caretPosition + 1 +} 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..cdda17bad --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/balloon/StringInputBalloon.kt @@ -0,0 +1,282 @@ +/******************************************************************************* + * 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.ScrollPaneFactory +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.JBUI +import com.redhat.devtools.intellij.kubernetes.createExplanationLabel +import com.redhat.devtools.intellij.kubernetes.insertNewLineAtCaret +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import java.awt.event.KeyListener +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseListener +import java.util.function.Supplier +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.text.JTextComponent +import kotlin.math.max +import kotlin.math.min + +class StringInputBalloon( + private val value: String, + private val setValue: (String) -> Unit, + private val editor: Editor +) { + + companion object { + private const val MAX_CHARACTERS = 64 + } + + 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 balloon = createBalloon(panel) + val textView = if (isMultiline() + || value.length > MAX_CHARACTERS) { + TextAreaPanel(value, balloon) + } else { + TextFieldPanel(value, balloon) + } + textView.addTo(panel) + val disposable = Disposer.newDisposable() + Disposer.register(balloon, disposable) + ComponentValidator(disposable) + .withValidator(ValueValidator(textView.textComponent)) + .installOn(textView.textComponent) + .andRegisterOnDocumentListener(textView.textComponent) + .revalidate() + balloon.addListener(onClosed(textView)) + return Pair(textView.textComponent, balloon) + } + + private fun createBalloon(panel: JPanel): Balloon { + return JBPopupFactory.getInstance() + .createBalloonBuilder(panel) + .setCloseButtonEnabled(true) + .setBlockClicksThroughBalloon(true) + .setAnimationCycle(0) + .setHideOnKeyOutside(true) + .setHideOnClickOutside(true) + .setFillColor(panel.background) + .setHideOnAction(false) // allow user to Ctrl+A & Ctrl+C + .createBalloon() + } + + private fun onClosed(view: CentralPanelView): JBPopupListener { + return object : JBPopupListener { + override fun beforeShown(event: LightweightWindowEvent) { + // do nothing + } + + override fun onClosed(event: LightweightWindowEvent) { + view.dispose() + } + } + } + + private fun onFocused(focusManager: IdeFocusManager, field: JTextComponent): ExpirableRunnable { + return object : ExpirableRunnable { + + override fun run() { + focusManager.requestFocus(field, true) + field.selectAll() + } + + override fun isExpired(): Boolean { + return false + } + } + } + + private fun isMultiline(): Boolean { + return value.contains('\n') + } + + private fun setValue(balloon: Balloon, textComponent: JTextComponent): Boolean { + return if (isValid) { + balloon.hide() + setValue.invoke(textComponent.text) + true + } else { + false + } + } + + private inner class ValueValidator(private val textComponent: JTextComponent) : Supplier { + + override fun get(): ValidationInfo? { + if (!textComponent.isEnabled + || !textComponent.isVisible + ) { + return null + } + return validate(textComponent.text) + } + + private fun validate(newValue: String): ValidationInfo? { + val validation = when { + StringUtil.isEmptyOrSpaces(newValue) -> + ValidationInfo("Provide a value", textComponent).asWarning() + + value == newValue -> + ValidationInfo("Provide new value", textComponent).asWarning() + + else -> + null + } + this@StringInputBalloon.isValid = (validation == null) + return validation + } + } + + interface CentralPanelView { + + var textComponent: JTextComponent + + fun addTo(panel: JPanel) + fun dispose() + } + + inner class TextFieldPanel(private val value: String, private val balloon: Balloon) : CentralPanelView { + + private val TEXTFIELD_COLUMNS = 30 + + override lateinit var textComponent: JTextComponent + private lateinit var keyListener: KeyListener + + override fun addTo(panel: JPanel) { + val label = JBLabel("Value:") + label.border = JBUI.Borders.empty(0, 3, 0, 1) + panel.add(label, BorderLayout.WEST) + val textField = JBTextField(value, TEXTFIELD_COLUMNS) + panel.add(textField, BorderLayout.CENTER) + keyListener = onKeyPressed(textField, balloon) + textField.addKeyListener(keyListener) + this.textComponent = textField + } + + override fun dispose() { + textComponent.removeKeyListener(keyListener) + } + + private fun onKeyPressed(textComponent: JTextComponent, balloon: Balloon): KeyListener { + return object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + when (e.keyCode) { + KeyEvent.VK_ESCAPE -> + balloon.hide() + + KeyEvent.VK_ENTER -> + setValue(balloon, textComponent) + } + } + } + } + } + + inner class TextAreaPanel(private val value: String, private val balloon: Balloon) : CentralPanelView { + + override lateinit var textComponent: JTextComponent + private lateinit var keyListener: KeyListener + + override fun addTo(panel: JPanel) { + val label = JBLabel("Value:") + label.border = JBUI.Borders.empty(0, 3, 4, 0) + panel.add(label, BorderLayout.NORTH) + val textArea = JBTextArea( + value, + value.length.floorDiv(MAX_CHARACTERS) + 1 + numOfNewLines(value), // textarea has text lines + 1 + MAX_CHARACTERS - 1 + ) + textArea.lineWrap = true + val scrolled = ScrollPaneFactory.createScrollPane(textArea, true) + panel.add(scrolled, BorderLayout.CENTER) + this.keyListener = onKeyPressed(textArea, balloon) + textArea.addKeyListener(keyListener) + this.textComponent = textArea + + val buttonPanel = JPanel(BorderLayout()) + buttonPanel.border = JBUI.Borders.empty(2, 0, 0, 0) + panel.add(buttonPanel, BorderLayout.SOUTH) + buttonPanel.add( + createExplanationLabel("Shift & Return to insert a new line, Return to apply"), + BorderLayout.CENTER) + val button = JButton("Apply") + button.addMouseListener(onApply(textArea, balloon)) + buttonPanel.add(button, BorderLayout.EAST) + } + + private fun onApply(textComponent: JTextComponent, balloon: Balloon): MouseListener { + return object: MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + setValue(balloon, textComponent) + } + } + } + + override fun dispose() { + textComponent.removeKeyListener(keyListener) + } + + private fun onKeyPressed(textComponent: JTextComponent, balloon: Balloon): KeyListener { + return object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + when { + KeyEvent.VK_ESCAPE == e.keyCode -> + balloon.hide() + + KeyEvent.VK_ENTER == e.keyCode + && (e.isShiftDown || e.isControlDown) -> { + insertNewLineAtCaret(textComponent) + } + + KeyEvent.VK_ENTER == e.keyCode + && (!e.isShiftDown && !e.isControlDown) -> + if (setValue(balloon, textComponent)) { + e.consume() + } + } + } + } + } + + private fun numOfNewLines(string: String): Int { + return string.count { char -> char == '\n' } + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64Presentations.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64Presentations.kt new file mode 100644 index 000000000..2e0615941 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64Presentations.kt @@ -0,0 +1,158 @@ +/******************************************************************************* + * 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.InlayHintsSink +import com.intellij.codeInsight.hints.presentation.InlayPresentation +import com.intellij.codeInsight.hints.presentation.PresentationFactory +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.redhat.devtools.intellij.common.validation.KubernetesResourceInfo +import com.redhat.devtools.intellij.kubernetes.balloon.StringInputBalloon +import com.redhat.devtools.intellij.kubernetes.editor.util.getBinaryData +import com.redhat.devtools.intellij.kubernetes.editor.util.getData +import com.redhat.devtools.intellij.kubernetes.editor.util.isKubernetesResource +import com.redhat.devtools.intellij.kubernetes.model.util.trimWithEllipsis +import org.jetbrains.concurrency.runAsync +import java.awt.event.MouseEvent + +/** + * A factory that creates an [InlayPresentationsFactory] that creates an [InlayPresentationsFactory] for the given kubernetes resource. + * The [InlayPresentationsFactory] creates [InlayPresentation]s for the properties in the resource. + * + * @see [create] + */ +class Base64Presentations { + + companion object { + private const val SECRET_RESOURCE_KIND = "Secret" + private const val CONFIGMAP_RESOURCE_KIND = "ConfigMap" + } + + fun create(content: PsiElement, info: KubernetesResourceInfo, editor: Editor, sink: InlayHintsSink): InlayPresentationsFactory? { + return when { + isKubernetesResource(SECRET_RESOURCE_KIND, info) -> + StringPresentationsFactory(content, editor, sink) + + isKubernetesResource(CONFIGMAP_RESOURCE_KIND, info) -> + BinaryPresentationsFactory(content, editor, sink) + + else -> null + } + + } + + abstract class InlayPresentationsFactory( + private val element: PsiElement, + private val editor: Editor, + private val sink: InlayHintsSink + ) { + + protected companion object { + const val INLAY_HINT_MAX_WIDTH = 50 + const val WRAP_BASE64_STRING_AT = 76 + } + + fun create(): Collection? { + return getChildren(element)?.children?.mapNotNull { child -> + val adapter = Base64ValueAdapter(child) + create(adapter, editor, sink) + } + } + + protected abstract fun getChildren(element: PsiElement): PsiElement? + + protected abstract fun create(adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation? + + } + + class StringPresentationsFactory(element: PsiElement, editor: Editor, sink: InlayHintsSink) + : InlayPresentationsFactory(element, editor, sink) { + + override fun create(adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation? { + val decoded = adapter.getDecoded() ?: return null + val offset = adapter.getStartOffset() ?: return null + val onClick = StringInputBalloon( + decoded, + onValidValue(adapter::set, editor.project), + editor + )::show + val presentation = create(decoded, onClick, editor) ?: return null + sink.addInlineElement(offset, false, presentation, false) + return presentation + } + + private fun create(text: String, onClick: (event: MouseEvent) -> Unit, editor: Editor): InlayPresentation? { + val factory = PresentationFactory(editor as EditorImpl) + val trimmed = trimWithEllipsis(text, INLAY_HINT_MAX_WIDTH) ?: return null + val textPresentation = factory.smallText(trimmed) + val hoverPresentation = factory.referenceOnHover(textPresentation) { event, _ -> + onClick.invoke(event) + } + val tooltipPresentation = factory.withTooltip("Click to change value", hoverPresentation) + val roundPresentation = factory.roundWithBackground(tooltipPresentation) + return roundPresentation + } + + private fun onValidValue(setter: (value: String, wrapAt: Int) -> Unit, project: Project?) + : (value: String) -> Unit { + return { value -> + runAsync { + WriteCommandAction.runWriteCommandAction(project) { + setter.invoke(value, WRAP_BASE64_STRING_AT) + } + } + } + } + + override fun getChildren(element: PsiElement): PsiElement? { + return getData(element) + } + + } + + class BinaryPresentationsFactory(element: PsiElement, editor: Editor, sink: InlayHintsSink) + : InlayPresentationsFactory(element, editor, sink) { + + override fun create(adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation? { + val decoded = adapter.getDecodedBytes() ?: return null + val offset = adapter.getStartOffset() ?: return null + val presentation = create(decoded, editor) ?: return null + sink.addInlineElement(offset, false, presentation, false) + return presentation + } + + private fun create(bytes: ByteArray, editor: Editor): InlayPresentation? { + val factory = PresentationFactory(editor as EditorImpl) + val hex = toHexString(bytes) ?: return null + val trimmed = trimWithEllipsis(hex, INLAY_HINT_MAX_WIDTH) ?: return null + return factory.roundWithBackground(factory.smallText(trimmed)) + } + + private fun toHexString(bytes: ByteArray): String? { + return try { + bytes.joinToString(separator = " ") { byte -> + Integer.toHexString(byte.toInt()) + } + } catch (e: Exception) { + null + } + } + + override fun getChildren(element: PsiElement): PsiElement? { + return getBinaryData(element) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueAdapter.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueAdapter.kt new file mode 100644 index 000000000..45a020141 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueAdapter.kt @@ -0,0 +1,86 @@ +/******************************************************************************* + * 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.editor.inlay + +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.redhat.devtools.intellij.kubernetes.editor.util.decodeBase64 +import com.redhat.devtools.intellij.kubernetes.editor.util.decodeBase64ToBytes +import com.redhat.devtools.intellij.kubernetes.editor.util.encodeBase64 +import com.redhat.devtools.intellij.kubernetes.editor.util.getValue +import com.redhat.devtools.intellij.kubernetes.editor.util.setValue + +class Base64ValueAdapter(private val element: PsiElement) { + + private companion object { + private val CONTENT_REGEX = Regex("[^\"\n |]*", RegexOption.MULTILINE) + private const val START_MULTILINE = "|\n" + private const val QUOTE = "\"" + } + + fun set(value: String, wrapAt: Int = -1) { + val possiblyMultiline = if (isMultiline()) { + wrap(wrapAt, START_MULTILINE + encodeBase64(value)) + } else { + encodeBase64(value) + } + ?: return + val possiblyQuoted = + if (isQuoted()) { + QUOTE + possiblyMultiline + QUOTE + } else { + possiblyMultiline + } + setValue(possiblyQuoted, element) + } + + private fun wrap(at: Int, string: String?): String? { + return when { + string == null -> null + at == -1 -> string + else -> { + string.chunked(at).joinToString("\n") + } + } + } + + fun get(): String? { + return getValue(element) + } + + private fun isMultiline(): Boolean { + return get()?.startsWith(START_MULTILINE) ?: false + } + + private fun isQuoted(): Boolean { + val value = get() ?: return false + return value.startsWith(QUOTE) + && value.endsWith(QUOTE) + } + + fun getDecoded(): String? { + val value = get() ?: return null + val content = CONTENT_REGEX + .findAll(value) + .filter { matchResult -> matchResult.value.isNotBlank() } + .map { matchResult -> matchResult.value } + .joinToString(separator = "") + return decodeBase64(content) + } + + fun getDecodedBytes(): ByteArray? { + return decodeBase64ToBytes(get()) + } + + fun getStartOffset(): Int? { + return com.redhat.devtools.intellij.kubernetes.editor.util.getStartOffset(element) + } +} \ No newline at end of file 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..ab20e9dd4 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueInlayHintsProvider.kt @@ -0,0 +1,70 @@ +/******************************************************************************* + * 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.openapi.editor.Editor +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.ui.dsl.builder.panel +import com.redhat.devtools.intellij.common.validation.KubernetesResourceInfo +import com.redhat.devtools.intellij.kubernetes.editor.util.getContent +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 info = KubernetesResourceInfo.extractMeta(file) ?: return null + return Collector(editor, info) + } + + private class Collector(editor: Editor, private val info: KubernetesResourceInfo) : FactoryInlayHintsCollector(editor) { + + override fun collect(element: PsiElement, editor: Editor, sink: InlayHintsSink): Boolean { + if (element !is PsiFile + || !element.isValid) { + return true + } + val content = getContent(element) ?: return true + val factory = Base64Presentations().create(content, info, editor, sink) ?: return true + factory.create() ?: return true + return false + } + } +} \ 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..daa8a7687 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,19 +18,25 @@ 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 import com.intellij.psi.PsiFile import com.intellij.psi.PsiManager +import com.intellij.refactoring.suggested.startOffset import com.redhat.devtools.intellij.common.validation.KubernetesResourceInfo 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 java.util.Base64 private const val KEY_METADATA = "metadata" +private const val KEY_DATA = "data" +private const val KEY_BINARY_DATA = "binaryData" private const val KEY_RESOURCE_VERSION = "resourceVersion" /** @@ -59,6 +66,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 +146,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,32 +173,184 @@ 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) } +/** + * Returns the [PsiElement] named "metadata" within the children of the given [PsiElement]. + * Only [YAMLKeyValue] and [JsonProperty] are supported. Returns `null` otherwise. + * + * @param element the PsiElement whose "metadata" child should be found. + * @return the PsiElement named "metadata" + */ private fun getMetadata(content: PsiElement): PsiElement? { return when (content) { is YAMLValue -> content.children - .filterIsInstance(YAMLKeyValue::class.java) + .filterIsInstance() .find { it.name == KEY_METADATA } is JsonValue -> content.children.toList() - .filterIsInstance(JsonProperty::class.java) + .filterIsInstance() .find { it.name == KEY_METADATA } else -> null } } +/** + * Returns the [PsiElement] named "data" within the children of the given [PsiElement]. + * Only [YAMLKeyValue] and [JsonProperty] are supported. Returns `null` otherwise. + * + * @param element the PsiElement whose "data" child should be found. + * @return the PsiElement named "data" + */ +fun getData(element: PsiElement): PsiElement? { + return when (element) { + is YAMLPsiElement -> + element.children + .filterIsInstance() + .find { it.name == KEY_DATA } + ?.value + is JsonElement -> + element.children.toList() + .filterIsInstance() + .find { it.name == KEY_DATA } + ?.value + else -> + null + } +} + +/** + * Returns the [PsiElement] named "binaryData" within the children of the given [PsiElement]. + * Only [YAMLKeyValue] and [JsonProperty] are supported. Returns `null` otherwise. + * + * @param element the PsiElement whose "binaryData" child should be found. + * @return the PsiElement named "binaryData" + */ +fun getBinaryData(element: PsiElement): PsiElement? { + return when (element) { + is YAMLPsiElement -> + element.children + .filterIsInstance() + .find { it.name == KEY_BINARY_DATA } + ?.value + is JsonElement -> + element.children.toList() + .filterIsInstance() + .find { it.name == KEY_BINARY_DATA } + ?.value + else -> + null + } +} + +/** + * Returns a base64 decoded String for the given base64 encoded String. + * Returns `null` if decoding fails. + * + * @param value the string to be decoded + * @return a decoded String for the given base64 encoded String. + */ +fun decodeBase64(value: String?): String? { + if (Strings.isEmptyOrSpaces(value)) { + return value + } + return try { + String(Base64.getDecoder().decode(value)) + } catch (e: IllegalArgumentException) { + null + } +} + +/** + * Returns base64 decoded bytes for the given base64 encoded string. + * Returns `null` if decoding fails. + * + * @param value the string to be decoded + * @return decoded bytes for the given base64 encoded string. + */ +fun decodeBase64ToBytes(value: String?): ByteArray? { + if (Strings.isEmptyOrSpaces(value)) { + return value?.toByteArray() + } + return try { + Base64.getDecoder().decode(value) + } catch (e: IllegalArgumentException) { + null + } +} + +/** + * Returns the base64 encoded string of the given string. + * Returns `null` if encoding fails. + * + * @param value the string to be encoded + * @return the base64 encoded string for the given string. + */ +fun encodeBase64(value: String): String? { + if (Strings.isEmptyOrSpaces(value)) { + return value + } + return try { + val bytes = Base64.getEncoder().encode(value.toByteArray()) + String(bytes) + } catch (e: IllegalArgumentException) { + null + } +} + +/** + * Returns the String value of the given [YAMLKeyValue] or [JsonProperty]. + * + * @param element the psi element to retrieve the startOffset from + * @return the startOffset in the value of the given psi element + */ +fun getValue(element: PsiElement): String? { + return when (element) { + is YAMLKeyValue -> element.value?.text + is JsonProperty -> element.value?.text + else -> null + } +} + +/** + * Returns the startOffset in the [YAMLValue] or [JsonValue] of the given [PsiElement]. + * Returns `null` otherwise. + * + * @param element the psi element to retrieve the startOffset from + * @return the startOffset in the value of the given psi element + */ +fun getStartOffset(element: PsiElement): Int? { + return when (element) { + is YAMLKeyValue -> element.value?.startOffset + is JsonProperty -> element.value?.startOffset + else -> null + } +} + +fun setValue(value: String, element: PsiElement) { + val newElement = when (element) { + is YAMLKeyValue -> + YAMLElementGenerator.getInstance(element.project).createYamlKeyValue(element.keyText, value) + is JsonProperty -> + JsonElementGenerator(element.project).createProperty(element.name, value) + else -> + null + } ?: return + element.parent.addAfter(newElement, element) + element.delete() +} + private fun getResourceVersion(metadata: YAMLKeyValue): YAMLKeyValue? { return metadata.value?.children - ?.filterIsInstance(YAMLKeyValue::class.java) + ?.filterIsInstance() ?.find { it.name == KEY_RESOURCE_VERSION } } private fun getResourceVersion(metadata: JsonProperty): JsonProperty? { return metadata.value?.children?.toList() - ?.filterIsInstance(JsonProperty::class.java) + ?.filterIsInstance() ?.find { it.name == KEY_RESOURCE_VERSION } } 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 @@ + diff --git a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueAdapterTest.kt b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueAdapterTest.kt new file mode 100644 index 000000000..edd496498 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueAdapterTest.kt @@ -0,0 +1,146 @@ +/******************************************************************************* + * 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.editor.inlay + +import com.intellij.json.psi.JsonProperty +import com.intellij.psi.PsiElement +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.redhat.devtools.intellij.kubernetes.editor.mocks.createJsonProperty +import com.redhat.devtools.intellij.kubernetes.editor.mocks.createJsonPsiFile +import com.redhat.devtools.intellij.kubernetes.editor.mocks.createJsonPsiFileFactory +import com.redhat.devtools.intellij.kubernetes.editor.mocks.createProjectWithServices +import com.redhat.devtools.intellij.kubernetes.editor.mocks.createPsiFileFactory +import com.redhat.devtools.intellij.kubernetes.editor.mocks.createYAMLGenerator +import com.redhat.devtools.intellij.kubernetes.editor.mocks.createYAMLKeyValue +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.yaml.psi.YAMLKeyValue +import org.junit.Test +import java.util.Base64 + +class Base64ValueAdapterTest { + + private val project = createProjectWithServices(createYAMLGenerator()) + + @Test + fun `#get should return value of YAMLKeyValue`() { + // given + val element = createYAMLKeyValue(value = "yoda", project = project) + val adapter = Base64ValueAdapter(element) + // when + val text = adapter.get() + // then + assertThat(text).isEqualTo("yoda") + } + + @Test + fun `#get should return value with quotes`() { + // given + val element = createYAMLKeyValue(value = "\"yoda\"", project = project) + val adapter = Base64ValueAdapter(element) + // when + val text = adapter.get() + // then + assertThat(text).isEqualTo("\"yoda\"") + } + + @Test + fun `#get should return value of JsonProperty`() { + // given + val element = createJsonProperty(value = "yoda", project = project) + val adapter = Base64ValueAdapter(element) + // when + val text = adapter.get() + // then + assertThat(text).isEqualTo("yoda") + } + + @Test + fun `#get should return null for unknown PsiElement`() { + // given + val element = mock() + val adapter = Base64ValueAdapter(element) + // when + val text = adapter.get() + // then + assertThat(text).isNull() + } + + @Test + fun `#getDecoded should return value decoded value`() { + // given + val element = createYAMLKeyValue(value = toBase64("skywalker"), project = project) + val adapter = Base64ValueAdapter(element) + // when + val text = adapter.getDecoded() + // then + assertThat(text).isEqualTo("skywalker") + } + + @Test + fun `#getDecoded should return null if value isnt valid base64`() { + // given + val element = createYAMLKeyValue(value = toBase64("skywalker") + "bogus", project = project) + val adapter = Base64ValueAdapter(element) + // when + val text = adapter.getDecoded() + // then + assertThat(text).isNull() + } + + @Test + fun `#getDecoded should return value without quotes`() { + // given + val element = createYAMLKeyValue(value = "\"" + toBase64("yoda") + "\"", project = project) + val adapter = Base64ValueAdapter(element) + // when + val text = adapter.getDecoded() + // then + assertThat(text).isEqualTo("yoda") + } + + @Test + fun `#set should add new YAMKeyValue to parent and delete current element`() { + // given + val parent = createYAMLKeyValue("group", "jedis", project = project) + val element = createYAMLKeyValue("jedi", "yoda", parent, project) + val adapter = Base64ValueAdapter(element) + // when + adapter.set("luke") + // then + verify(parent).addAfter(any(), eq(element)) + verify(element).delete() + } + + @Test + fun `#set should add new JsonProperty to parent and delete current element`() { + // given + val properties: MutableList = mutableListOf() + val psiFileFactory = createJsonPsiFileFactory(properties) + val project = createProjectWithServices(psiFileFactory = psiFileFactory) + val parent = createJsonProperty("group", "jedis", project = project) + val property = createJsonProperty("jedi", "yoda", parent, project) + properties.add(property) + val adapter = Base64ValueAdapter(property) + // when + adapter.set("luke") + // then + verify(parent).addAfter(any(), eq(property)) + verify(property).delete() + } + + private fun toBase64(value: String): String { + return String(Base64.getEncoder().encode(value.toByteArray())) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/mocks/PsiElementMocks.kt b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/mocks/PsiElementMocks.kt new file mode 100644 index 000000000..9bc36833c --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/mocks/PsiElementMocks.kt @@ -0,0 +1,110 @@ +/******************************************************************************* + * 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.editor.mocks + +import com.intellij.json.JsonFileType +import com.intellij.json.psi.JsonElementGenerator +import com.intellij.json.psi.JsonObject +import com.intellij.json.psi.JsonProperty +import com.intellij.json.psi.JsonValue +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileFactory +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import org.jetbrains.yaml.YAMLElementGenerator +import org.jetbrains.yaml.psi.YAMLKeyValue +import org.jetbrains.yaml.psi.YAMLValue +import kotlin.random.Random + +fun createYAMLKeyValue( + key: String = Random.nextInt().toString(), + value: String? = null, + parent: YAMLKeyValue? = null, + project: Project +): YAMLKeyValue { + val valueElement: YAMLValue = mock { + on { getText() } doReturn value + } + return mock { + on { getKeyText() } doReturn key + on { getValue() } doReturn valueElement + on { getParent() } doReturn parent + on { getProject() } doReturn project + } +} + +fun createJsonProperty( + name: String = Random.nextInt().toString(), + value: String? = null, + parent: JsonProperty? = null, + project: Project +): JsonProperty { + val valueElement: JsonValue = mock { + on { getText() } doReturn value + } + return mock { + on { getValue() } doReturn valueElement + on { getName() } doReturn name + on { getValue() } doReturn valueElement + on { getParent() } doReturn parent + on { getProject() } doReturn project + } +} + +fun createProjectWithServices( + yamlGenerator: YAMLElementGenerator? = null, + psiFileFactory: PsiFileFactory? = null +): Project { + return mock { + on { getService(any>()) } doAnswer { invocation -> + when { + YAMLElementGenerator::class.java == invocation.getArgument>(0) -> + yamlGenerator + + PsiFileFactory::class.java == invocation.getArgument>(0) -> + psiFileFactory + + else -> null + } + } + } +} + +fun createJsonPsiFile(properties: List): PsiFile { + val firstChild: JsonObject = mock { + on { getPropertyList() } doReturn properties + } + return mock { + on { getFirstChild() } doReturn firstChild + } +} + +fun createJsonPsiFileFactory(properties: List): PsiFileFactory { + val file = createJsonPsiFile(properties) + return createPsiFileFactory(file) +} + +fun createPsiFileFactory(psiFile: PsiFile): PsiFileFactory { + return mock { + on { createFileFromText(any(), any(), any()) } doReturn psiFile + } +} + +fun createYAMLGenerator(): YAMLElementGenerator { + return mock { + on { createYamlKeyValue(any(), any()) } doReturn mock() + } +}