Skip to content

Commit eb612c5

Browse files
committed
feat: show/allow setting decoded base64 values in Secrets, ConfigMaps (redhat-developer#663)
Signed-off-by: Andre Dietisheim <[email protected]>
1 parent 0a05c6a commit eb612c5

File tree

6 files changed

+497
-3
lines changed

6 files changed

+497
-3
lines changed

.github/workflows/IJ.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
runs-on: ubuntu-latest
1515
strategy:
1616
matrix:
17-
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]
17+
IJ: [IC-2021.3, IC-2022.1, IC-2022.2, IC-2022.3, IC-2023.1, IC-2023.2, IC-2023.3]
1818

1919
steps:
2020
- uses: actions/checkout@v2

gradle.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
ideaVersion=IC-2022.1
1+
ideaVersion=IC-2021.3
22
# build number ranges
33
# https://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/build_number_ranges.html
44
sinceIdeaBuild=221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2024 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.balloon
12+
13+
import com.intellij.openapi.editor.Editor
14+
import com.intellij.openapi.ui.ComponentValidator
15+
import com.intellij.openapi.ui.ValidationInfo
16+
import com.intellij.openapi.ui.popup.Balloon
17+
import com.intellij.openapi.ui.popup.JBPopupFactory
18+
import com.intellij.openapi.ui.popup.JBPopupListener
19+
import com.intellij.openapi.ui.popup.LightweightWindowEvent
20+
import com.intellij.openapi.util.Disposer
21+
import com.intellij.openapi.util.ExpirableRunnable
22+
import com.intellij.openapi.util.text.StringUtil
23+
import com.intellij.openapi.wm.IdeFocusManager
24+
import com.intellij.ui.ScrollPaneFactory
25+
import com.intellij.ui.awt.RelativePoint
26+
import com.intellij.ui.components.JBLabel
27+
import com.intellij.ui.components.JBTextArea
28+
import com.intellij.ui.components.JBTextField
29+
import com.intellij.util.ui.JBUI
30+
import java.awt.BorderLayout
31+
import java.awt.Dimension
32+
import java.awt.FlowLayout
33+
import java.awt.event.KeyAdapter
34+
import java.awt.event.KeyEvent
35+
import java.awt.event.MouseEvent
36+
import java.util.function.Supplier
37+
import javax.swing.BoxLayout
38+
import javax.swing.JComponent
39+
import javax.swing.JPanel
40+
import javax.swing.JTextArea
41+
import javax.swing.text.JTextComponent
42+
import kotlin.math.max
43+
44+
class StringInputBalloon(private val value: String, private val onValidValue: (String) -> Unit, private val editor: Editor) {
45+
46+
companion object {
47+
private const val MAX_WIDTH = 220.0
48+
private const val MAX_CHARACTERS = 64
49+
}
50+
51+
private var isValid = false
52+
53+
fun show(event: MouseEvent) {
54+
val (field, balloon) = create()
55+
balloon.show(RelativePoint(event), Balloon.Position.above)
56+
val focusManager = IdeFocusManager.getInstance(editor.project)
57+
focusManager.doWhenFocusSettlesDown(onFocused(focusManager, field))
58+
}
59+
60+
private fun create(): Pair<JTextComponent, Balloon> {
61+
val panel = JPanel(BorderLayout())
62+
val textComponent = if (value.contains('\n')) {
63+
createTextArea(panel)
64+
} else {
65+
createTextField(panel)
66+
}
67+
val balloon = createBalloon(panel)
68+
val disposable = Disposer.newDisposable()
69+
Disposer.register(balloon, disposable)
70+
ComponentValidator(disposable)
71+
.withValidator(ValueValidator(textComponent))
72+
.installOn(textComponent)
73+
.andRegisterOnDocumentListener(textComponent)
74+
.revalidate()
75+
val keyListener = onKeyPressed(textComponent, balloon)
76+
textComponent.addKeyListener(keyListener)
77+
balloon.addListener(onClosed(textComponent, keyListener))
78+
return Pair(textComponent, balloon)
79+
}
80+
81+
private fun createTextField(panel: JPanel): JBTextField {
82+
val label = JBLabel("Value:")
83+
label.border = JBUI.Borders.empty(0, 3, 0, 1)
84+
panel.add(label, BorderLayout.WEST)
85+
val field = JBTextField(value)
86+
field.preferredSize = Dimension(
87+
max(MAX_WIDTH, field.preferredSize.width.toDouble()).toInt(),
88+
field.preferredSize.height
89+
)
90+
panel.add(field, BorderLayout.CENTER)
91+
return field
92+
}
93+
94+
private fun createTextArea(panel: JPanel): JTextArea {
95+
val label = JBLabel("Value:")
96+
label.border = JBUI.Borders.empty(0, 3, 4, 0)
97+
panel.add(label, BorderLayout.NORTH)
98+
val textArea = JBTextArea(value,
99+
value.length.floorDiv(MAX_CHARACTERS - 1) + 2, // textarea has text lines + 1
100+
MAX_CHARACTERS - 1)
101+
val scrolled = ScrollPaneFactory.createScrollPane(textArea, true)
102+
panel.add(scrolled, BorderLayout.CENTER)
103+
return textArea
104+
}
105+
106+
private fun createBalloon(panel: JPanel): Balloon {
107+
return JBPopupFactory.getInstance()
108+
.createBalloonBuilder(panel)
109+
.setCloseButtonEnabled(true)
110+
.setBlockClicksThroughBalloon(true)
111+
.setAnimationCycle(0)
112+
.setHideOnKeyOutside(true)
113+
.setHideOnClickOutside(true)
114+
.setFillColor(panel.background)
115+
.setHideOnAction(false) // allow user to Ctrl+A & Ctrl+C
116+
.createBalloon()
117+
}
118+
119+
private fun onClosed(field: JTextComponent, keyListener: KeyAdapter): JBPopupListener {
120+
return object : JBPopupListener {
121+
override fun beforeShown(event: LightweightWindowEvent) {}
122+
override fun onClosed(event: LightweightWindowEvent) {
123+
field.removeKeyListener(keyListener)
124+
}
125+
}
126+
}
127+
128+
private fun onKeyPressed(textComponent: JTextComponent, balloon: Balloon) = object : KeyAdapter() {
129+
override fun keyPressed(e: KeyEvent) {
130+
when (e.keyCode) {
131+
KeyEvent.VK_ESCAPE ->
132+
balloon.hide()
133+
KeyEvent.VK_ENTER ->
134+
if (isValid) {
135+
balloon.hide()
136+
onValidValue.invoke(textComponent.text)
137+
}
138+
}
139+
}
140+
}
141+
142+
private fun onFocused(focusManager: IdeFocusManager, field: JTextComponent): ExpirableRunnable {
143+
return object : ExpirableRunnable {
144+
145+
override fun run() {
146+
focusManager.requestFocus(field, true)
147+
field.selectAll()
148+
}
149+
150+
override fun isExpired(): Boolean {
151+
return false
152+
}
153+
}
154+
}
155+
156+
private inner class ValueValidator(private val textComponent: JTextComponent) : Supplier<ValidationInfo?> {
157+
158+
override fun get(): ValidationInfo? {
159+
if (!textComponent.isEnabled
160+
|| !textComponent.isVisible
161+
) {
162+
return null
163+
}
164+
return validate(textComponent.text)
165+
}
166+
167+
private fun validate(newValue: String): ValidationInfo? {
168+
val validation = when {
169+
StringUtil.isEmptyOrSpaces(newValue) ->
170+
ValidationInfo("Provide a value", textComponent).asWarning()
171+
value == newValue ->
172+
ValidationInfo("Provide new value", textComponent).asWarning()
173+
else ->
174+
null
175+
}
176+
this@StringInputBalloon.isValid = (validation == null)
177+
return validation
178+
}
179+
}
180+
181+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2024 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
@file:Suppress("UnstableApiUsage")
12+
13+
package com.redhat.devtools.intellij.kubernetes.editor.inlay
14+
15+
import com.intellij.codeInsight.hints.ChangeListener
16+
import com.intellij.codeInsight.hints.FactoryInlayHintsCollector
17+
import com.intellij.codeInsight.hints.ImmediateConfigurable
18+
import com.intellij.codeInsight.hints.InlayHintsCollector
19+
import com.intellij.codeInsight.hints.InlayHintsProvider
20+
import com.intellij.codeInsight.hints.InlayHintsSink
21+
import com.intellij.codeInsight.hints.NoSettings
22+
import com.intellij.codeInsight.hints.SettingsKey
23+
import com.intellij.codeInsight.hints.presentation.InlayPresentation
24+
import com.intellij.codeInsight.hints.presentation.PresentationFactory
25+
import com.intellij.openapi.command.WriteCommandAction
26+
import com.intellij.openapi.editor.Editor
27+
import com.intellij.openapi.editor.impl.EditorImpl
28+
import com.intellij.openapi.project.Project
29+
import com.intellij.psi.PsiElement
30+
import com.intellij.psi.PsiFile
31+
import com.intellij.ui.dsl.builder.panel
32+
import com.redhat.devtools.intellij.common.validation.KubernetesResourceInfo
33+
import com.redhat.devtools.intellij.kubernetes.balloon.StringInputBalloon
34+
import com.redhat.devtools.intellij.kubernetes.editor.util.decodeBase64
35+
import com.redhat.devtools.intellij.kubernetes.editor.util.encodeBase64
36+
import com.redhat.devtools.intellij.kubernetes.editor.util.getBinaryData
37+
import com.redhat.devtools.intellij.kubernetes.editor.util.getContent
38+
import com.redhat.devtools.intellij.kubernetes.editor.util.getData
39+
import com.redhat.devtools.intellij.kubernetes.editor.util.getStartOffset
40+
import com.redhat.devtools.intellij.kubernetes.editor.util.getValue
41+
import com.redhat.devtools.intellij.kubernetes.editor.util.isKubernetesResource
42+
import com.redhat.devtools.intellij.kubernetes.editor.util.setValue
43+
import com.redhat.devtools.intellij.kubernetes.model.util.trimWithEllipsis
44+
import org.jetbrains.concurrency.runAsync
45+
import java.awt.event.MouseEvent
46+
import javax.swing.JComponent
47+
48+
49+
internal class Base64ValueInlayHintsProvider : InlayHintsProvider<NoSettings> {
50+
51+
companion object {
52+
private const val SECRET_RESOURCE_KIND = "Secret"
53+
private const val CONFIGMAP_RESOURCE_KIND = "ConfigMap"
54+
}
55+
56+
override val key: SettingsKey<NoSettings> = SettingsKey("KubernetesResource.hints")
57+
override val name: String = "Kubernetes"
58+
override val previewText: String = "Preview"
59+
60+
override fun createSettings(): NoSettings {
61+
return NoSettings()
62+
}
63+
64+
override fun createConfigurable(settings: NoSettings): ImmediateConfigurable {
65+
return object : ImmediateConfigurable {
66+
override fun createComponent(listener: ChangeListener): JComponent = panel {}
67+
68+
override val mainCheckboxText: String = "Show hints for:"
69+
70+
override val cases: List<ImmediateConfigurable.Case> = emptyList()
71+
}
72+
}
73+
74+
override fun getCollectorFor(file: PsiFile, editor: Editor, settings: NoSettings, sink: InlayHintsSink): InlayHintsCollector? {
75+
val info = KubernetesResourceInfo.extractMeta(file) ?: return null
76+
if (!isKubernetesResource(SECRET_RESOURCE_KIND, info)
77+
&& !isKubernetesResource(CONFIGMAP_RESOURCE_KIND, info)) {
78+
return null
79+
}
80+
return Collector(editor, info)
81+
}
82+
83+
private class Collector(editor: Editor, private val info: KubernetesResourceInfo) : FactoryInlayHintsCollector(editor) {
84+
85+
override fun collect(element: PsiElement, editor: Editor, sink: InlayHintsSink): Boolean {
86+
if (element !is PsiFile
87+
|| !element.isValid) {
88+
return true
89+
}
90+
val content = getContent(element) ?: return true
91+
val base64Element = getBase64Element(info, content) ?: return true
92+
createPresentations(base64Element, editor, sink)
93+
return false
94+
}
95+
96+
private fun getBase64Element(info: KubernetesResourceInfo?, content: PsiElement): PsiElement? {
97+
return when {
98+
isKubernetesResource(SECRET_RESOURCE_KIND, info) ->
99+
// all entries in 'data' are base64 encoded
100+
getData(content)
101+
102+
isKubernetesResource(CONFIGMAP_RESOURCE_KIND, info) ->
103+
// all entries in 'binaryData' are base64 encoded
104+
getBinaryData(content)
105+
106+
else -> null
107+
}
108+
}
109+
110+
private fun createPresentations(element: PsiElement, editor: Editor, sink: InlayHintsSink): Collection<InlayPresentation> {
111+
return element.children.mapNotNull { child ->
112+
val adapter = Base64ValueAdapter(child)
113+
val decoded = adapter.getDecoded()
114+
if (decoded != null) {
115+
val onClick = StringInputBalloon(
116+
decoded,
117+
onValidValue(adapter::set, editor.project),
118+
editor
119+
)::show
120+
createPresentation(onClick, adapter, editor, sink)
121+
} else {
122+
null
123+
}
124+
}
125+
}
126+
127+
private fun createPresentation(onClick: (event: MouseEvent) -> Unit, adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation? {
128+
val offset = adapter.getStartOffset() ?: return null
129+
val decoded = adapter.getDecoded() ?: return null
130+
val presentation = createPresentation(decoded, onClick, editor) ?: return null
131+
sink.addInlineElement(offset, false, presentation, false)
132+
return presentation
133+
}
134+
135+
private fun createPresentation(text: String, onClick: (event: MouseEvent) -> Unit, editor: Editor): InlayPresentation? {
136+
val factory = PresentationFactory(editor as EditorImpl)
137+
val trimmed = trimWithEllipsis(text, 50) ?: return null
138+
val textPresentation = factory.smallText(trimmed)
139+
val hoverPresentation = factory.referenceOnHover(textPresentation) { event, translated ->
140+
onClick.invoke(event)
141+
}
142+
val tooltipPresentation = factory.withTooltip("Click to change value", hoverPresentation)
143+
val roundPresentation = factory.roundWithBackground(tooltipPresentation)
144+
return roundPresentation
145+
}
146+
147+
fun onValidValue(setter: (value: String, project: Project?) -> Unit, project: Project?): (value: String) -> Unit {
148+
return { value ->
149+
runAsync {
150+
WriteCommandAction.runWriteCommandAction(project) {
151+
setter.invoke(value, project)
152+
}
153+
}
154+
}
155+
}
156+
}
157+
158+
private class Base64ValueAdapter(private val element: PsiElement) {
159+
160+
private companion object {
161+
private val CONTENT_REGEX = Regex("[^ |\n]*", setOf(RegexOption.MULTILINE))
162+
private const val START_MULTILINE = "|\n"
163+
}
164+
165+
fun set(value: String, project: Project?) {
166+
val toSet = if (isMultiline()) {
167+
START_MULTILINE + encodeBase64(value)
168+
?.chunked(76)
169+
?.joinToString("\n")
170+
} else {
171+
encodeBase64(value)
172+
}
173+
?: return
174+
setValue(toSet, element, project)
175+
}
176+
177+
fun get(): String? {
178+
return getValue(element)
179+
}
180+
181+
fun isMultiline(): Boolean {
182+
val value = get()
183+
return value?.startsWith(START_MULTILINE) ?: false
184+
}
185+
186+
fun getDecoded(): String? {
187+
val value = get() ?: return null
188+
val content = CONTENT_REGEX
189+
.findAll(value)
190+
.filter { matchResult -> matchResult.value.isNotBlank() }
191+
.map { matchResult -> matchResult.value }
192+
.joinToString(separator = "")
193+
return decodeBase64(content)
194+
}
195+
196+
fun getStartOffset(): Int? {
197+
return getStartOffset(element)
198+
}
199+
}
200+
}

0 commit comments

Comments
 (0)