Skip to content

Commit

Permalink
feat: show/allow setting decoded base64 values in Secrets, ConfigMaps (
Browse files Browse the repository at this point in the history
…#663)

Signed-off-by: Andre Dietisheim <[email protected]>
  • Loading branch information
adietish committed Feb 16, 2024
1 parent 0a05c6a commit eb612c5
Show file tree
Hide file tree
Showing 6 changed files with 497 additions and 3 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/IJ.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-2021.3, IC-2022.1, IC-2022.2, IC-2022.3, IC-2023.1, IC-2023.2, IC-2023.3]

steps:
- uses: actions/checkout@v2
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ideaVersion=IC-2022.1
ideaVersion=IC-2021.3
# build number ranges
# https://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/build_number_ranges.html
sinceIdeaBuild=221
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*******************************************************************************
* 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 java.awt.BorderLayout
import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
import java.util.function.Supplier
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.text.JTextComponent
import kotlin.math.max

class StringInputBalloon(private val value: String, private val onValidValue: (String) -> Unit, private val editor: Editor) {

companion object {
private const val MAX_WIDTH = 220.0
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<JTextComponent, Balloon> {
val panel = JPanel(BorderLayout())
val textComponent = if (value.contains('\n')) {
createTextArea(panel)
} else {
createTextField(panel)
}
val balloon = createBalloon(panel)
val disposable = Disposer.newDisposable()
Disposer.register(balloon, disposable)
ComponentValidator(disposable)
.withValidator(ValueValidator(textComponent))
.installOn(textComponent)
.andRegisterOnDocumentListener(textComponent)
.revalidate()
val keyListener = onKeyPressed(textComponent, balloon)
textComponent.addKeyListener(keyListener)
balloon.addListener(onClosed(textComponent, keyListener))
return Pair(textComponent, balloon)
}

private fun createTextField(panel: JPanel): JBTextField {
val label = JBLabel("Value:")
label.border = JBUI.Borders.empty(0, 3, 0, 1)
panel.add(label, BorderLayout.WEST)
val field = JBTextField(value)
field.preferredSize = Dimension(
max(MAX_WIDTH, field.preferredSize.width.toDouble()).toInt(),
field.preferredSize.height
)
panel.add(field, BorderLayout.CENTER)
return field
}

private fun createTextArea(panel: JPanel): JTextArea {
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) + 2, // textarea has text lines + 1
MAX_CHARACTERS - 1)
val scrolled = ScrollPaneFactory.createScrollPane(textArea, true)
panel.add(scrolled, BorderLayout.CENTER)
return textArea
}

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(field: JTextComponent, keyListener: KeyAdapter): JBPopupListener {
return object : JBPopupListener {
override fun beforeShown(event: LightweightWindowEvent) {}
override fun onClosed(event: LightweightWindowEvent) {
field.removeKeyListener(keyListener)
}
}
}

private fun onKeyPressed(textComponent: JTextComponent, 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(textComponent.text)
}
}
}
}

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 inner class ValueValidator(private val textComponent: JTextComponent) : Supplier<ValidationInfo?> {

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
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*******************************************************************************
* 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.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.intellij.psi.PsiFile
import com.intellij.ui.dsl.builder.panel
import com.redhat.devtools.intellij.common.validation.KubernetesResourceInfo
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.getBinaryData
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.getStartOffset
import com.redhat.devtools.intellij.kubernetes.editor.util.getValue
import com.redhat.devtools.intellij.kubernetes.editor.util.isKubernetesResource
import com.redhat.devtools.intellij.kubernetes.editor.util.setValue
import com.redhat.devtools.intellij.kubernetes.model.util.trimWithEllipsis
import org.jetbrains.concurrency.runAsync
import java.awt.event.MouseEvent
import javax.swing.JComponent


internal class Base64ValueInlayHintsProvider : InlayHintsProvider<NoSettings> {

companion object {
private const val SECRET_RESOURCE_KIND = "Secret"
private const val CONFIGMAP_RESOURCE_KIND = "ConfigMap"
}

override val key: SettingsKey<NoSettings> = 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<ImmediateConfigurable.Case> = emptyList()
}
}

override fun getCollectorFor(file: PsiFile, editor: Editor, settings: NoSettings, sink: InlayHintsSink): InlayHintsCollector? {
val info = KubernetesResourceInfo.extractMeta(file) ?: return null
if (!isKubernetesResource(SECRET_RESOURCE_KIND, info)
&& !isKubernetesResource(CONFIGMAP_RESOURCE_KIND, info)) {
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 base64Element = getBase64Element(info, content) ?: return true
createPresentations(base64Element, editor, sink)
return false
}

private fun getBase64Element(info: KubernetesResourceInfo?, content: PsiElement): PsiElement? {
return when {
isKubernetesResource(SECRET_RESOURCE_KIND, info) ->
// all entries in 'data' are base64 encoded
getData(content)

isKubernetesResource(CONFIGMAP_RESOURCE_KIND, info) ->
// all entries in 'binaryData' are base64 encoded
getBinaryData(content)

else -> null
}
}

private fun createPresentations(element: PsiElement, editor: Editor, sink: InlayHintsSink): Collection<InlayPresentation> {
return element.children.mapNotNull { child ->
val adapter = Base64ValueAdapter(child)
val decoded = adapter.getDecoded()
if (decoded != null) {
val onClick = StringInputBalloon(
decoded,
onValidValue(adapter::set, editor.project),
editor
)::show
createPresentation(onClick, adapter, editor, sink)
} else {
null
}
}
}

private fun createPresentation(onClick: (event: MouseEvent) -> Unit, adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation? {
val offset = adapter.getStartOffset() ?: return null
val decoded = adapter.getDecoded() ?: return null
val presentation = createPresentation(decoded, onClick, editor) ?: return null
sink.addInlineElement(offset, false, presentation, false)
return presentation
}

private fun createPresentation(text: String, onClick: (event: MouseEvent) -> Unit, editor: Editor): InlayPresentation? {
val factory = PresentationFactory(editor as EditorImpl)
val trimmed = trimWithEllipsis(text, 50) ?: return null
val textPresentation = factory.smallText(trimmed)
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
}

fun onValidValue(setter: (value: String, project: Project?) -> Unit, project: Project?): (value: String) -> Unit {
return { value ->
runAsync {
WriteCommandAction.runWriteCommandAction(project) {
setter.invoke(value, project)
}
}
}
}
}

private class Base64ValueAdapter(private val element: PsiElement) {

private companion object {
private val CONTENT_REGEX = Regex("[^ |\n]*", setOf(RegexOption.MULTILINE))
private const val START_MULTILINE = "|\n"
}

fun set(value: String, project: Project?) {
val toSet = if (isMultiline()) {
START_MULTILINE + encodeBase64(value)
?.chunked(76)
?.joinToString("\n")
} else {
encodeBase64(value)
}
?: return
setValue(toSet, element, project)
}

fun get(): String? {
return getValue(element)
}

fun isMultiline(): Boolean {
val value = get()
return value?.startsWith(START_MULTILINE) ?: false
}

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 getStartOffset(): Int? {
return getStartOffset(element)
}
}
}
Loading

0 comments on commit eb612c5

Please sign in to comment.