Skip to content

Commit 8821fe0

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 8821fe0

File tree

9 files changed

+835
-6
lines changed

9 files changed

+835
-6
lines changed

.github/workflows/IJ.yml

Lines changed: 1 addition & 1 deletion
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-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
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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.event.KeyAdapter
33+
import java.awt.event.KeyEvent
34+
import java.awt.event.KeyListener
35+
import java.awt.event.MouseEvent
36+
import java.util.function.Supplier
37+
import javax.swing.JPanel
38+
import javax.swing.JTextArea
39+
import javax.swing.text.JTextComponent
40+
import kotlin.math.max
41+
42+
class StringInputBalloon(
43+
private val value: String,
44+
private val onValidValue: (String) -> Unit,
45+
private val editor: Editor
46+
) {
47+
48+
companion object {
49+
private const val MAX_WIDTH = 220.0
50+
private const val MAX_CHARACTERS = 64
51+
}
52+
53+
private var isValid = false
54+
55+
fun show(event: MouseEvent) {
56+
val (field, balloon) = create()
57+
balloon.show(RelativePoint(event), Balloon.Position.above)
58+
val focusManager = IdeFocusManager.getInstance(editor.project)
59+
focusManager.doWhenFocusSettlesDown(onFocused(focusManager, field))
60+
}
61+
62+
private fun create(): Pair<JTextComponent, Balloon> {
63+
val panel = JPanel(BorderLayout())
64+
val textComponent = if (value.contains('\n')) {
65+
createTextArea(panel)
66+
} else {
67+
createTextField(panel)
68+
}
69+
val balloon = createBalloon(panel)
70+
val disposable = Disposer.newDisposable()
71+
Disposer.register(balloon, disposable)
72+
ComponentValidator(disposable)
73+
.withValidator(ValueValidator(textComponent))
74+
.installOn(textComponent)
75+
.andRegisterOnDocumentListener(textComponent)
76+
.revalidate()
77+
val keyListener = onKeyPressed(textComponent, balloon)
78+
textComponent.addKeyListener(keyListener)
79+
balloon.addListener(onClosed(textComponent, keyListener))
80+
return Pair(textComponent, balloon)
81+
}
82+
83+
private fun createTextField(panel: JPanel): JBTextField {
84+
val label = JBLabel("Value:")
85+
label.border = JBUI.Borders.empty(0, 3, 0, 1)
86+
panel.add(label, BorderLayout.WEST)
87+
val field = JBTextField(value)
88+
field.preferredSize = Dimension(
89+
max(MAX_WIDTH, field.preferredSize.width.toDouble()).toInt(),
90+
field.preferredSize.height
91+
)
92+
panel.add(field, BorderLayout.CENTER)
93+
return field
94+
}
95+
96+
private fun createTextArea(panel: JPanel): JTextArea {
97+
val label = JBLabel("Value:")
98+
label.border = JBUI.Borders.empty(0, 3, 4, 0)
99+
panel.add(label, BorderLayout.NORTH)
100+
val textArea = JBTextArea(
101+
value,
102+
value.length.floorDiv(MAX_CHARACTERS - 1) + 2, // textarea has text lines + 1
103+
MAX_CHARACTERS - 1
104+
)
105+
val scrolled = ScrollPaneFactory.createScrollPane(textArea, true)
106+
panel.add(scrolled, BorderLayout.CENTER)
107+
return textArea
108+
}
109+
110+
private fun createBalloon(panel: JPanel): Balloon {
111+
return JBPopupFactory.getInstance()
112+
.createBalloonBuilder(panel)
113+
.setCloseButtonEnabled(true)
114+
.setBlockClicksThroughBalloon(true)
115+
.setAnimationCycle(0)
116+
.setHideOnKeyOutside(true)
117+
.setHideOnClickOutside(true)
118+
.setFillColor(panel.background)
119+
.setHideOnAction(false) // allow user to Ctrl+A & Ctrl+C
120+
.createBalloon()
121+
}
122+
123+
private fun onClosed(field: JTextComponent, keyListener: KeyListener): JBPopupListener {
124+
return object : JBPopupListener {
125+
override fun beforeShown(event: LightweightWindowEvent) {
126+
// do nothing
127+
}
128+
129+
override fun onClosed(event: LightweightWindowEvent) {
130+
field.removeKeyListener(keyListener)
131+
}
132+
}
133+
}
134+
135+
private fun onKeyPressed(textComponent: JTextComponent, balloon: Balloon): KeyListener {
136+
return object : KeyAdapter() {
137+
override fun keyPressed(e: KeyEvent) {
138+
when (e.keyCode) {
139+
KeyEvent.VK_ESCAPE ->
140+
balloon.hide()
141+
142+
KeyEvent.VK_ENTER ->
143+
if (isValid) {
144+
balloon.hide()
145+
onValidValue.invoke(textComponent.text)
146+
}
147+
}
148+
}
149+
}
150+
}
151+
152+
private fun onFocused(focusManager: IdeFocusManager, field: JTextComponent): ExpirableRunnable {
153+
return object : ExpirableRunnable {
154+
155+
override fun run() {
156+
focusManager.requestFocus(field, true)
157+
field.selectAll()
158+
}
159+
160+
override fun isExpired(): Boolean {
161+
return false
162+
}
163+
}
164+
}
165+
166+
private inner class ValueValidator(private val textComponent: JTextComponent) : Supplier<ValidationInfo?> {
167+
168+
override fun get(): ValidationInfo? {
169+
if (!textComponent.isEnabled
170+
|| !textComponent.isVisible
171+
) {
172+
return null
173+
}
174+
return validate(textComponent.text)
175+
}
176+
177+
private fun validate(newValue: String): ValidationInfo? {
178+
val validation = when {
179+
StringUtil.isEmptyOrSpaces(newValue) ->
180+
ValidationInfo("Provide a value", textComponent).asWarning()
181+
182+
value == newValue ->
183+
ValidationInfo("Provide new value", textComponent).asWarning()
184+
185+
else ->
186+
null
187+
}
188+
this@StringInputBalloon.isValid = (validation == null)
189+
return validation
190+
}
191+
}
192+
193+
}
Lines changed: 152 additions & 0 deletions
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+
@file:Suppress("UnstableApiUsage")
12+
package com.redhat.devtools.intellij.kubernetes.editor.inlay
13+
14+
import com.intellij.codeInsight.hints.InlayHintsSink
15+
import com.intellij.codeInsight.hints.presentation.InlayPresentation
16+
import com.intellij.codeInsight.hints.presentation.PresentationFactory
17+
import com.intellij.openapi.command.WriteCommandAction
18+
import com.intellij.openapi.editor.Editor
19+
import com.intellij.openapi.editor.impl.EditorImpl
20+
import com.intellij.openapi.project.Project
21+
import com.intellij.psi.PsiElement
22+
import com.redhat.devtools.intellij.common.validation.KubernetesResourceInfo
23+
import com.redhat.devtools.intellij.kubernetes.balloon.StringInputBalloon
24+
import com.redhat.devtools.intellij.kubernetes.editor.util.getBinaryData
25+
import com.redhat.devtools.intellij.kubernetes.editor.util.getData
26+
import com.redhat.devtools.intellij.kubernetes.editor.util.isKubernetesResource
27+
import com.redhat.devtools.intellij.kubernetes.model.util.trimWithEllipsis
28+
import org.jetbrains.concurrency.runAsync
29+
import java.awt.event.MouseEvent
30+
31+
class Base64PresentationsFactory {
32+
33+
companion object {
34+
private const val SECRET_RESOURCE_KIND = "Secret"
35+
private const val CONFIGMAP_RESOURCE_KIND = "ConfigMap"
36+
private const val WRAP_BASE64_AT = 76
37+
}
38+
39+
fun create(content: PsiElement, info: KubernetesResourceInfo, editor: Editor, sink: InlayHintsSink): InlayPresentationsFactory? {
40+
return when {
41+
isKubernetesResource(SECRET_RESOURCE_KIND, info) ->
42+
StringPresentationsFactory(content, editor, sink)
43+
44+
isKubernetesResource(CONFIGMAP_RESOURCE_KIND, info) ->
45+
BinaryPresentationsFactory(content, editor, sink)
46+
47+
else -> null
48+
}
49+
50+
}
51+
52+
abstract class InlayPresentationsFactory(
53+
private val element: PsiElement,
54+
private val editor: Editor,
55+
private val sink: InlayHintsSink
56+
) {
57+
58+
companion object {
59+
const val INLAY_HINT_MAX_WIDTH = 50
60+
}
61+
62+
fun create(): Collection<InlayPresentation>? {
63+
return getChildren(element)?.children?.mapNotNull { child ->
64+
val adapter = Base64ValueAdapter(child)
65+
create(adapter, editor, sink)
66+
}
67+
}
68+
69+
protected abstract fun getChildren(element: PsiElement): PsiElement?
70+
71+
protected abstract fun create(adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation?
72+
73+
}
74+
75+
class StringPresentationsFactory(element: PsiElement, editor: Editor, sink: InlayHintsSink)
76+
: InlayPresentationsFactory(element, editor, sink) {
77+
78+
override fun create(adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation? {
79+
val decoded = adapter.getDecoded() ?: return null
80+
val offset = adapter.getStartOffset() ?: return null
81+
val onClick = StringInputBalloon(
82+
decoded,
83+
onValidValue(adapter::set, editor.project),
84+
editor
85+
)::show
86+
val presentation = create(decoded, onClick, editor) ?: return null
87+
sink.addInlineElement(offset, false, presentation, false)
88+
return presentation
89+
}
90+
91+
private fun create(text: String, onClick: (event: MouseEvent) -> Unit, editor: Editor): InlayPresentation? {
92+
val factory = PresentationFactory(editor as EditorImpl)
93+
val trimmed = trimWithEllipsis(text, INLAY_HINT_MAX_WIDTH) ?: return null
94+
val textPresentation = factory.smallText(trimmed)
95+
val hoverPresentation = factory.referenceOnHover(textPresentation) { event, _ ->
96+
onClick.invoke(event)
97+
}
98+
val tooltipPresentation = factory.withTooltip("Click to change value", hoverPresentation)
99+
val roundPresentation = factory.roundWithBackground(tooltipPresentation)
100+
return roundPresentation
101+
}
102+
103+
private fun onValidValue(setter: (value: String, wrapAt: Int) -> Unit, project: Project?)
104+
: (value: String) -> Unit {
105+
return { value ->
106+
runAsync {
107+
WriteCommandAction.runWriteCommandAction(project) {
108+
setter.invoke(value, WRAP_BASE64_AT)
109+
}
110+
}
111+
}
112+
}
113+
114+
override fun getChildren(element: PsiElement): PsiElement? {
115+
return getData(element)
116+
}
117+
118+
}
119+
120+
class BinaryPresentationsFactory(element: PsiElement, editor: Editor, sink: InlayHintsSink)
121+
: InlayPresentationsFactory(element, editor, sink) {
122+
123+
override fun create(adapter: Base64ValueAdapter, editor: Editor, sink: InlayHintsSink): InlayPresentation? {
124+
val decoded = adapter.getDecodedBytes() ?: return null
125+
val offset = adapter.getStartOffset() ?: return null
126+
val presentation = create(decoded, editor) ?: return null
127+
sink.addInlineElement(offset, false, presentation, false)
128+
return presentation
129+
}
130+
131+
private fun create(bytes: ByteArray, editor: Editor): InlayPresentation? {
132+
val factory = PresentationFactory(editor as EditorImpl)
133+
val hex = toHexString(bytes) ?: return null
134+
val trimmed = trimWithEllipsis(hex, INLAY_HINT_MAX_WIDTH) ?: return null
135+
return factory.roundWithBackground(factory.smallText(trimmed))
136+
}
137+
138+
private fun toHexString(bytes: ByteArray): String? {
139+
return try {
140+
bytes.joinToString(separator = " ") { byte ->
141+
Integer.toHexString(byte.toInt())
142+
}
143+
} catch (e: Exception) {
144+
null
145+
}
146+
}
147+
148+
override fun getChildren(element: PsiElement): PsiElement? {
149+
return getBinaryData(element)
150+
}
151+
}
152+
}

0 commit comments

Comments
 (0)