Skip to content

Commit 55c2b5f

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 55c2b5f

File tree

6 files changed

+417
-3
lines changed

6 files changed

+417
-3
lines changed

Diff for: .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

Diff for: 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,152 @@
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.awt.RelativePoint
25+
import com.intellij.ui.components.JBLabel
26+
import com.intellij.ui.components.JBTextField
27+
import com.intellij.util.ui.JBUI
28+
import org.jetbrains.annotations.NotNull
29+
import java.awt.BorderLayout
30+
import java.awt.Dimension
31+
import java.awt.event.KeyAdapter
32+
import java.awt.event.KeyEvent
33+
import java.awt.event.MouseEvent
34+
import java.util.function.BiConsumer
35+
import java.util.function.Supplier
36+
import javax.swing.JPanel
37+
import javax.swing.JTextField
38+
import kotlin.math.max
39+
40+
41+
class StringInputBalloon(@NotNull private val onValidValue: (String) -> Unit, @NotNull private val editor: Editor) {
42+
43+
companion object {
44+
private const val MAX_WIDTH = 220.0
45+
}
46+
47+
private var isValid = false
48+
49+
fun show(event: MouseEvent) {
50+
val (field, balloon) = create()
51+
balloon.show(RelativePoint(event), Balloon.Position.above)
52+
val focusManager = IdeFocusManager.getInstance(editor.project)
53+
focusManager.doWhenFocusSettlesDown(onFocused(focusManager, field))
54+
}
55+
56+
private fun create(): Pair<JBTextField, Balloon> {
57+
val panel = JPanel(BorderLayout())
58+
val label = JBLabel("Value:")
59+
label.border = JBUI.Borders.empty(0, 3, 0, 1)
60+
panel.add(label, BorderLayout.WEST)
61+
val field = JBTextField()
62+
field.preferredSize = Dimension(
63+
max(MAX_WIDTH, field.preferredSize.width.toDouble()).toInt(),
64+
field.preferredSize.height
65+
)
66+
panel.add(field, BorderLayout.CENTER)
67+
val balloon = createBalloon(panel)
68+
val disposable = Disposer.newDisposable()
69+
Disposer.register(balloon, disposable)
70+
ComponentValidator(disposable)
71+
.withValidator(ValueValidator(field))
72+
.installOn(field)
73+
.andRegisterOnDocumentListener(field)
74+
.revalidate()
75+
val keyListener = onKeyPressed(field, balloon)
76+
field.addKeyListener(keyListener)
77+
balloon.addListener(onClosed(field, keyListener))
78+
return Pair(field, balloon)
79+
}
80+
81+
private fun createBalloon(panel: JPanel): Balloon {
82+
return JBPopupFactory.getInstance()
83+
.createBalloonBuilder(panel)
84+
.setCloseButtonEnabled(false)
85+
.setBlockClicksThroughBalloon(true)
86+
.setAnimationCycle(0)
87+
.setHideOnKeyOutside(true)
88+
.setHideOnClickOutside(true)
89+
.setFillColor(panel.background)
90+
.createBalloon()
91+
}
92+
93+
private fun onClosed(field: JBTextField, keyListener: KeyAdapter): JBPopupListener {
94+
return object : JBPopupListener {
95+
override fun beforeShown(event: LightweightWindowEvent) {}
96+
override fun onClosed(event: LightweightWindowEvent) {
97+
field.removeKeyListener(keyListener)
98+
}
99+
}
100+
}
101+
102+
private fun onKeyPressed(field: JTextField, balloon: Balloon) = object : KeyAdapter() {
103+
override fun keyPressed(e: KeyEvent) {
104+
when (e.keyCode) {
105+
KeyEvent.VK_ESCAPE ->
106+
balloon.hide()
107+
KeyEvent.VK_ENTER ->
108+
if (isValid) {
109+
balloon.hide()
110+
onValidValue.invoke(field.text)
111+
}
112+
}
113+
}
114+
}
115+
116+
private fun onFocused(focusManager: IdeFocusManager, field: JBTextField): ExpirableRunnable {
117+
return object : ExpirableRunnable {
118+
119+
override fun run() {
120+
focusManager.requestFocus(field, true)
121+
field.selectAll()
122+
}
123+
124+
override fun isExpired(): Boolean {
125+
return false
126+
}
127+
}
128+
}
129+
130+
private inner class ValueValidator(private val field: JTextField) : Supplier<ValidationInfo?> {
131+
132+
override fun get(): ValidationInfo? {
133+
if (!field.isEnabled
134+
|| !field.isVisible
135+
) {
136+
return null
137+
}
138+
return validate(field.text)
139+
}
140+
141+
private fun validate(name: String): ValidationInfo? {
142+
val validation = if (StringUtil.isEmptyOrSpaces(name)) {
143+
ValidationInfo("Provide a value", field).asWarning()
144+
} else {
145+
null
146+
}
147+
this@StringInputBalloon.isValid = (validation == null)
148+
return validation
149+
}
150+
}
151+
152+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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.kubernetes.balloon.StringInputBalloon
33+
import com.redhat.devtools.intellij.kubernetes.editor.util.decodeBase64
34+
import com.redhat.devtools.intellij.kubernetes.editor.util.encodeBase64
35+
import com.redhat.devtools.intellij.kubernetes.editor.util.getContent
36+
import com.redhat.devtools.intellij.kubernetes.editor.util.getData
37+
import com.redhat.devtools.intellij.kubernetes.editor.util.getKubernetesResourceInfo
38+
import com.redhat.devtools.intellij.kubernetes.editor.util.getStartOffset
39+
import com.redhat.devtools.intellij.kubernetes.editor.util.getValue
40+
import com.redhat.devtools.intellij.kubernetes.editor.util.isKubernetesResource
41+
import com.redhat.devtools.intellij.kubernetes.editor.util.setValue
42+
import org.jetbrains.concurrency.runAsync
43+
import java.awt.event.MouseEvent
44+
import javax.swing.JComponent
45+
46+
47+
internal class Base64ValueInlayHintsProvider : InlayHintsProvider<NoSettings> {
48+
49+
override val key: SettingsKey<NoSettings> = SettingsKey("KubernetesResource.hints")
50+
override val name: String = "Kubernetes"
51+
override val previewText: String = "Preview"
52+
53+
override fun createSettings(): NoSettings {
54+
return NoSettings()
55+
}
56+
57+
override fun createConfigurable(settings: NoSettings): ImmediateConfigurable {
58+
return object : ImmediateConfigurable {
59+
override fun createComponent(listener: ChangeListener): JComponent = panel {}
60+
61+
override val mainCheckboxText: String = "Show hints for:"
62+
63+
override val cases: List<ImmediateConfigurable.Case> = emptyList()
64+
}
65+
}
66+
67+
override fun getCollectorFor(
68+
file: PsiFile,
69+
editor: Editor,
70+
settings: NoSettings,
71+
sink: InlayHintsSink
72+
): InlayHintsCollector? {
73+
val project = editor.project ?: return null
74+
val virtualFile = file.virtualFile ?: return null
75+
val info = getKubernetesResourceInfo(virtualFile, project)
76+
if (!isKubernetesResource("Secret", info)
77+
&& !isKubernetesResource("ConfigMap", info)
78+
) {
79+
return null
80+
}
81+
return Collector(editor)
82+
}
83+
84+
private class Collector(editor: Editor) : FactoryInlayHintsCollector(editor) {
85+
86+
override fun collect(element: PsiElement, editor: Editor, sink: InlayHintsSink): Boolean {
87+
if (!element.isValid) {
88+
return true
89+
}
90+
val content = getContent(element) ?: return true
91+
val data = getData(content) ?: return true
92+
data.children.toList()
93+
.forEach { child ->
94+
// all entries in 'data' are base64 encoded
95+
val adapter = Base64ValueAdapter(child)
96+
val onClick = StringInputBalloon(onValidValue(adapter::set, editor.project), editor)::show
97+
return createInlayHint(onClick, adapter, editor, sink)
98+
}
99+
100+
return true
101+
}
102+
103+
private fun createInlayHint(onClick: (event: MouseEvent) -> Unit, adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): Boolean {
104+
val offset = adapter.getStartOffset() ?: return true
105+
val decoded = adapter.getDecoded() ?: return true
106+
val presentation = createPresentation(decoded, onClick, editor) ?: return true
107+
sink.addInlineElement(offset, false, presentation, false)
108+
return false
109+
}
110+
111+
private fun createPresentation(decoded: String, onClick: (event: MouseEvent) -> Unit, editor: Editor): InlayPresentation? {
112+
val factory = PresentationFactory(editor as EditorImpl)
113+
val textPresentation = factory.smallText(decoded)
114+
val hoverPresentation = factory.referenceOnHover(textPresentation) { event, translated ->
115+
onClick.invoke(event)
116+
}
117+
val tooltipPresentation = factory.withTooltip("Click to change value", hoverPresentation)
118+
val roundPresentation = factory.roundWithBackground(tooltipPresentation)
119+
return roundPresentation
120+
}
121+
122+
fun onValidValue(setter: (value: String, project: Project?) -> Unit, project: Project?): (value: String) -> Unit {
123+
return { value ->
124+
runAsync {
125+
WriteCommandAction.runWriteCommandAction(project) {
126+
setter.invoke(value, project)
127+
}
128+
}
129+
}
130+
}
131+
}
132+
133+
private class Base64ValueAdapter(private val element: PsiElement) {
134+
135+
private companion object {
136+
private val CONTENT_REGEX = Regex("[^ |\n]*", setOf(RegexOption.MULTILINE))
137+
private val PIPE_CHARACTER = '|'
138+
}
139+
140+
fun set(value: String, project: Project?) {
141+
val encoded = encodeBase64(value) ?: return
142+
setValue(encoded, element, project)
143+
}
144+
145+
fun get(): String? {
146+
return getValue(element)
147+
}
148+
149+
fun getDecoded(): String? {
150+
val value = getValue(element) ?: return null
151+
val content = CONTENT_REGEX
152+
.findAll(value)
153+
.filter { matchResult -> matchResult.value.isNotBlank() }
154+
.map { matchResult -> matchResult.value }
155+
.joinToString(separator = "")
156+
return decodeBase64(content)
157+
}
158+
159+
fun startsWithPipe(): Boolean {
160+
return get()?.startsWith(PIPE_CHARACTER) ?: false
161+
}
162+
163+
fun getStartOffset(): Int? {
164+
return getStartOffset(element)
165+
}
166+
}
167+
}

0 commit comments

Comments
 (0)