-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: show/allow setting decoded base64 values in Secrets, ConfigMaps (…
…#663) Signed-off-by: Andre Dietisheim <[email protected]>
- Loading branch information
Showing
6 changed files
with
497 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
181 changes: 181 additions & 0 deletions
181
src/main/kotlin/com/redhat/devtools/intellij/kubernetes/balloon/StringInputBalloon.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
|
||
} |
200 changes: 200 additions & 0 deletions
200
...lin/com/redhat/devtools/intellij/kubernetes/editor/inlay/Base64ValueInlayHintsProvider.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
Oops, something went wrong.