Skip to content

Commit

Permalink
feat: show/allow setting decoded base64 values in Secrets (#663)
Browse files Browse the repository at this point in the history
Signed-off-by: Andre Dietisheim <[email protected]>
  • Loading branch information
adietish committed Feb 16, 2024
1 parent 0a05c6a commit d1dfea8
Show file tree
Hide file tree
Showing 6 changed files with 417 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,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<JBTextField, Balloon> {
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<ValidationInfo?> {

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

}
Original file line number Diff line number Diff line change
@@ -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.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.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.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 org.jetbrains.concurrency.runAsync
import java.awt.event.MouseEvent
import javax.swing.JComponent


internal class Base64ValueInlayHintsProvider : InlayHintsProvider<NoSettings> {

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 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 ->
// all entries in 'data' are base64 encoded
val adapter = Base64ValueAdapter(child)
val onClick = StringInputBalloon(onValidValue(adapter::set, editor.project), editor)::show
return createInlayHint(onClick, adapter, editor, sink)
}

return true
}

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

private fun createPresentation(decoded: String, onClick: (event: MouseEvent) -> Unit, editor: Editor): InlayPresentation? {
val factory = PresentationFactory(editor as EditorImpl)
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
}

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 val PIPE_CHARACTER = '|'
}

fun set(value: String, project: Project?) {
val encoded = encodeBase64(value) ?: return
setValue(encoded, element, project)
}

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

fun getDecoded(): String? {
val value = getValue(element) ?: return null
val content = CONTENT_REGEX
.findAll(value)
.filter { matchResult -> matchResult.value.isNotBlank() }
.map { matchResult -> matchResult.value }
.joinToString(separator = "")
return decodeBase64(content)
}

fun startsWithPipe(): Boolean {
return get()?.startsWith(PIPE_CHARACTER) ?: false
}

fun getStartOffset(): Int? {
return getStartOffset(element)
}
}
}
Loading

0 comments on commit d1dfea8

Please sign in to comment.