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 19, 2024
1 parent 0a05c6a commit 32fabbf
Show file tree
Hide file tree
Showing 8 changed files with 666 additions and 7 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,193 @@
/*******************************************************************************
* 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.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.KeyListener
import java.awt.event.MouseEvent
import java.util.function.Supplier
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: KeyListener): JBPopupListener {
return object : JBPopupListener {
override fun beforeShown(event: LightweightWindowEvent) {
// do nothing
}

override fun onClosed(event: LightweightWindowEvent) {
field.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 ->
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,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
******************************************************************************/
@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

class Base64PresentationsFactory {

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
) {

companion object {
const val INLAY_HINT_MAX_WIDTH = 50
}

fun create(): Collection<InlayPresentation>? {
return getChildren(element)?.children?.mapNotNull { child ->
create(Base64ValueAdapter(child), 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 getChildren(element: PsiElement): PsiElement? {
return getData(element)
}

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
}

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

}

class BinaryPresentationsFactory(element: PsiElement, editor: Editor, sink: InlayHintsSink) :
InlayPresentationsFactory(element, editor, sink) {

override fun getChildren(element: PsiElement): PsiElement? {
return getBinaryData(element)
}

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

0 comments on commit 32fabbf

Please sign in to comment.