Skip to content

Commit c7a567c

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 c7a567c

File tree

8 files changed

+594
-5
lines changed

8 files changed

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

0 commit comments

Comments
 (0)