From dbbc254dd5cfa6f4919134fca0c5065bfd8f4491 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Mon, 6 Apr 2026 09:04:22 +0200 Subject: [PATCH 01/11] Add support for "algo=none" to the JWT decoding/encoding tool --- CHANGELOG.md | 2 + .../tool/ui/converter/JwtEncoderDecoder.kt | 45 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f836ca..0200b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add support for "algo=none" to the JWT decoding/encoding tool + ### Added ### Changed diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt index 44b826d..019611e 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt @@ -69,8 +69,10 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEnco import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SecretKeyEncodingMode.BASE64 import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SecretKeyEncodingMode.RAW import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithm.HMAC256 +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithm.NONE import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.ECDSA import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.HMAC +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.NONE as NONE_KIND import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.RSA import java.security.Key import java.security.KeyFactory @@ -272,7 +274,7 @@ class JwtEncoderDecoder( ) ) } - .visibleIf(ComboBoxPredicate(signatureAlgorithmComboBox) { it?.kind?.keyFactory == null }) + .visibleIf(ComboBoxPredicate(signatureAlgorithmComboBox) { it?.kind == HMAC }) .layout(RowLayout.PARENT_GRID) row { @@ -297,6 +299,7 @@ class JwtEncoderDecoder( "The RFC 7518 for the JSON Web Algorithms (JWA) specifies some restrictions that a key or secret should fulfill for the computation of a signature (e.g., a minimum length). This option can be used to enforce these restrictions." ) } + .visibleIf(ComboBoxPredicate(signatureAlgorithmComboBox) { it?.kind != NONE_KIND }) } .apply { expanded = false } .topGap(TopGap.NONE) @@ -619,6 +622,7 @@ class JwtEncoderDecoder( enum class SignatureAlgorithmKind(val keyFactory: KeyFactory?) { + NONE(null), HMAC(null), RSA(KeyFactory.getInstance("RSA")), ECDSA(KeyFactory.getInstance("EC")), @@ -633,6 +637,7 @@ class JwtEncoderDecoder( val algorithmIdentifiers: String, ) { + NONE("none", NONE_KIND, "none"), HMAC256("HS256", HMAC, HMAC_SHA256), HMAC384("HS384", HMAC, HMAC_SHA384), HMAC512("HS512", HMAC, HMAC_SHA512), @@ -643,7 +648,12 @@ class JwtEncoderDecoder( ECDSA384("ES384", ECDSA, ECDSA_USING_P384_CURVE_AND_SHA384), ECDSA512("ES512", ECDSA, ECDSA_USING_P521_CURVE_AND_SHA512); - override fun toString(): String = "$name ($jwtHeaderValue)" + override fun toString(): String = + if (this == NONE) { + "None" + } else { + "$name ($jwtHeaderValue)" + } companion object { @@ -672,7 +682,7 @@ class JwtEncoderDecoder( fun decodeJwt() { clearErrorHolders() - val jwtParts = encoded.get().split(".") + val jwtParts = encoded.get().split('.', limit = 3) val numOfJwtParts = jwtParts.size // Header @@ -703,17 +713,23 @@ class JwtEncoderDecoder( } // Signature - if (numOfJwtParts >= 3 && headerErrorHolder.isNotSet()) { - signature.compute(jwtParts[0], jwtParts[1])?.let { expectedSignature -> - val actualSignature = jwtParts[2] - if (expectedSignature != actualSignature) { - signatureErrorHolder.add( - "Invalid signature. Check the configuration in the 'Signature Algorithm Configuration' section." - ) + if (headerErrorHolder.isNotSet()) { + val actualSignature = jwtParts.getOrElse(2) { "" } + if (signature.algorithm.get() == NONE) { + if (actualSignature.isNotEmpty()) { + signatureErrorHolder.add("JWT with algorithm 'none' must not contain a signature") } + } else if (numOfJwtParts >= 3) { + signature.compute(jwtParts[0], jwtParts[1])?.let { expectedSignature -> + if (expectedSignature != actualSignature) { + signatureErrorHolder.add( + "Invalid signature. Check the configuration in the 'Signature Algorithm Configuration' section." + ) + } + } + } else { + signatureErrorHolder.add("Encoded JWT does not have a signature part") } - } else { - signatureErrorHolder.add("Encoded JWT does not have a signature part") } } @@ -843,6 +859,9 @@ class JwtEncoderDecoder( privateKeyErrorHolder.clear() return try { + if (algorithm.get() == NONE) { + return "" + } val signingKey = createSigningKey() ?: return null ExtendedJsonWebSignature() .apply { @@ -863,6 +882,7 @@ class JwtEncoderDecoder( private fun createSigningKey(): Key? { val signatureAlgorithm = algorithm.get() return when (signatureAlgorithm.kind) { + NONE_KIND -> null HMAC -> HmacKey( when (secretEncodingMode.get()) { @@ -903,6 +923,7 @@ class JwtEncoderDecoder( private fun loadExampleSecrets() { val privateKeyValue = privateKey.get() when (algorithm.get().kind) { + NONE_KIND -> {} HMAC -> { if (secret.get().isBlank()) { secret.set(EXAMPLE_SECRET) From b4158f4f3358e8d1cef60390f8ab85953dfc473f Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Sat, 25 Apr 2026 15:22:39 +0200 Subject: [PATCH 02/11] feat: Added support for validating JWTs via public keys and JWKS. Overhauled the JWT tool UI. --- CHANGELOG.md | 7 +- .../tool/ui/common/TitledTabbedPane.kt | 4 +- .../tool/ui/common/UiExtensions.kt | 23 +- .../tool/ui/converter/JwtEncoderDecoder.kt | 1135 ----------------- .../jwtencoderdecoder/JwtEditorHighlighter.kt | 203 +++ .../jwtencoderdecoder/JwtEncoderDecoder.kt | 739 +++++++++++ .../jwtencoderdecoder/JwtEncodingModel.kt | 496 +++++++ .../jwtencoderdecoder/JwtValidation.kt | 439 +++++++ .../message/UiToolsBundle.properties | 98 +- .../message/UiToolsBundle_de.properties | 97 ++ .../tool/ui/common/TitledTabbedPaneTest.kt | 29 + .../testfixtures/DeveloperUiToolUnderTest.kt | 5 +- src/main/resources/META-INF/plugin.xml | 8 +- 13 files changed, 2135 insertions(+), 1148 deletions(-) delete mode 100644 modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt create mode 100644 modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEditorHighlighter.kt create mode 100644 modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt create mode 100644 modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncodingModel.kt create mode 100644 modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtValidation.kt create mode 100644 modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPaneTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0200b99..2eb020e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ ## Unreleased -- Add support for "algo=none" to the JWT decoding/encoding tool - ### Added +- Added support for algo=none to the JWT tool. +- Added support for validating JWTs via public keys and JWKS. +- Overhauled the JWT tool UI. + + ### Changed ### Removed diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt index d1a7e9d..4a78317 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt @@ -14,13 +14,13 @@ class TitledTabbedPane(title: String, tabs: List>) : JB // -- Initialization ------------------------------------------------------ // init { - tabComponentInsets = JBUI.insetsTop(8) + tabComponentInsets = JBUI.emptyInsets() addTab("", JPanel()) setTabComponentAt(0, JBLabel(title).apply { font = font?.deriveFont(Font.BOLD) }) setEnabledAt(0, false) - tabs.forEach { addTab(it.first, it.second) } + tabs.forEach { addTab(it.first, it.second.wrapTabbedPaneContent()) } selectedIndex = 1 setUI(TitleTabAwareTabbedPaneUi()) diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt index 42594f4..38e9c4e 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt @@ -9,6 +9,9 @@ import com.intellij.openapi.editor.highlighter.EditorHighlighterFactory import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.ui.getUserData +import com.intellij.openapi.ui.putUserData +import com.intellij.openapi.util.Key import com.intellij.ui.HyperlinkLabel import com.intellij.ui.JBColor import com.intellij.ui.UIBundle @@ -24,6 +27,7 @@ import com.intellij.ui.tabs.TabInfo import com.intellij.util.ui.JBEmptyBorder import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Color import java.awt.Font @@ -214,6 +218,16 @@ fun JComponent.wrapWithToolBar( } } +fun JComponent.wrapTabbedPaneContent(topInset: Int = 8): JComponent { + return BorderLayoutPanel().apply { + isOpaque = true + background = UIUtil.getPanelBackground() + border = JBUI.Borders.emptyTop(topInset) + putUserData(wrappedTabbedPaneContentKey, this@wrapTabbedPaneContent) + addToCenter(this@wrapTabbedPaneContent) + } +} + fun JComponent.withNoRightBorderInset(): JComponent { val insets = border?.getBorderInsets(this) ?: JBUI.emptyInsets() border = JBUI.Borders.empty(insets.top, insets.left, insets.bottom, 0) @@ -287,7 +301,12 @@ fun Cell.registerDynamicToolTip(toolTipText: () -> String?) { } fun JBTabbedPane.onSelectionChanged(onSelectionChanged: (JComponent) -> Unit): JBTabbedPane { - addChangeListener { onSelectionChanged(selectedComponent as JComponent) } + addChangeListener { + val selectedComponent = selectedComponent as? JComponent ?: return@addChangeListener + onSelectionChanged( + selectedComponent.getUserData(wrappedTabbedPaneContentKey) ?: selectedComponent + ) + } return this } @@ -308,3 +327,5 @@ enum class ToolBarPlace(val horizontal: Boolean) { RIGHT(false), APPEND(true), } + +private val wrappedTabbedPaneContentKey = Key.create("wrappedTabbedPaneContent") diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt deleted file mode 100644 index 019611e..0000000 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/JwtEncoderDecoder.kt +++ /dev/null @@ -1,1135 +0,0 @@ -package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode -import com.intellij.icons.AllIcons -import com.intellij.json.JsonLanguage -import com.intellij.lang.Language -import com.intellij.openapi.Disposable -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.editor.colors.EditorColors -import com.intellij.openapi.editor.colors.EditorColorsManager -import com.intellij.openapi.editor.markup.GutterIconRenderer -import com.intellij.openapi.editor.markup.HighlighterLayer -import com.intellij.openapi.fileTypes.PlainTextLanguage -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.ComboBox -import com.intellij.openapi.ui.Splitter -import com.intellij.openapi.util.TextRange -import com.intellij.ui.IconManager -import com.intellij.ui.dsl.builder.Align -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.BottomGap -import com.intellij.ui.dsl.builder.LabelPosition -import com.intellij.ui.dsl.builder.Panel -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.TopGap -import com.intellij.ui.dsl.builder.actionButton -import com.intellij.ui.dsl.builder.bindItem -import com.intellij.ui.dsl.builder.bindSelected -import com.intellij.ui.dsl.builder.bindText -import com.intellij.ui.dsl.builder.panel -import com.intellij.ui.dsl.builder.rows -import com.intellij.ui.dsl.builder.selected -import com.intellij.ui.dsl.builder.whenItemSelectedFromUi -import com.intellij.ui.dsl.builder.whenStateChangedFromUi -import com.intellij.ui.dsl.builder.whenTextChangedFromUi -import com.intellij.ui.layout.ComboBoxPredicate -import com.intellij.ui.layout.not -import com.intellij.util.Alarm -import com.intellij.util.ExceptionUtil -import com.intellij.util.text.DateFormatUtil -import dev.turingcomplete.intellijdevelopertoolsplugin.common.ValueProperty -import dev.turingcomplete.intellijdevelopertoolsplugin.common.capitalize -import dev.turingcomplete.intellijdevelopertoolsplugin.common.decodeBase64String -import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration -import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.CONFIGURATION -import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.INPUT -import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.SENSITIVE -import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolsApplicationSettings.Companion.generalSettings -import dev.turingcomplete.intellijdevelopertoolsplugin.settings.GeneralSettings.Companion.createSensitiveInputsHandlingToolTipText -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.ObjectMapperService -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiTool -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolContext -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolFactory -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolPresentation -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor.EditorMode -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.ErrorHolder -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.SimpleToggleAction -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.UiUtils -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.registerDynamicToolTip -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.setValidationResultBorder -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.ChangeOrigin.ENCODED -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.ChangeOrigin.HEADER_OR_PAYLOAD -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.ChangeOrigin.SIGNATURE_CONFIGURATION -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SecretKeyEncodingMode.BASE32 -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SecretKeyEncodingMode.BASE64 -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SecretKeyEncodingMode.RAW -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithm.HMAC256 -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithm.NONE -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.ECDSA -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.HMAC -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.NONE as NONE_KIND -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder.SignatureAlgorithmKind.RSA -import java.security.Key -import java.security.KeyFactory -import java.security.spec.PKCS8EncodedKeySpec -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Base64 -import java.util.Objects -import java.util.StringJoiner -import javax.swing.Icon -import javax.swing.JComponent -import org.apache.commons.codec.binary.Base32 -import org.jose4j.jws.AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256 -import org.jose4j.jws.AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384 -import org.jose4j.jws.AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512 -import org.jose4j.jws.AlgorithmIdentifiers.HMAC_SHA256 -import org.jose4j.jws.AlgorithmIdentifiers.HMAC_SHA384 -import org.jose4j.jws.AlgorithmIdentifiers.HMAC_SHA512 -import org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA256 -import org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA384 -import org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA512 -import org.jose4j.jws.JsonWebSignature -import org.jose4j.keys.HmacKey - -class JwtEncoderDecoder( - private val context: DeveloperUiToolContext, - private val configuration: DeveloperToolConfiguration, - parentDisposable: Disposable, - private val project: Project?, -) : DeveloperUiTool(parentDisposable) { - // -- Properties ---------------------------------------------------------- // - - private var liveConversion = configuration.register("liveConversion", true) - private var encodedText = configuration.register("encodedText", "", INPUT, EXAMPLE_ENCODED) - private var headerText = configuration.register("headerText", "", INPUT, EXAMPLE_HEADER) - private var payloadText = configuration.register("payloadText", "", INPUT, EXAMPLE_PAYLOAD) - - private val highlightEncodedAlarm by lazy { Alarm(parentDisposable) } - private val highlightHeaderAlarm by lazy { Alarm(parentDisposable) } - private val highlightPayloadAlarm by lazy { Alarm(parentDisposable) } - private val conversionAlarm by lazy { Alarm(parentDisposable) } - - private var lastActiveInput: AdvancedEditor? = null - private val encodedEditor by lazy { createEncodedEditor() } - private val headerEditor by lazy { createHeaderEditor() } - private val payloadEditor by lazy { createPayloadEditor() } - - private val highlightingAttributes by lazy { - EditorColorsManager.getInstance() - .globalScheme - .getAttributes(EditorColors.SEARCH_RESULT_ATTRIBUTES) - } - - private val jwt = Jwt(configuration, encodedText, headerText, payloadText) - - // -- Initialization ------------------------------------------------------ // - - init { - liveConversion.afterChange(parentDisposable) { handleLiveConversionSwitch() } - - jwt.signature.secretEncodingMode.afterChangeConsumeEvent(null) { e -> - if (e.valueChanged()) { - convertFromUi(SIGNATURE_CONFIGURATION) - } - } - } - - // -- Exposed Methods ----------------------------------------------------- // - - override fun Panel.buildUi() { - row { - cell( - Splitter(true, 0.2f).apply { - firstComponent = createEncodedComponent() - secondComponent = createEncodingDecodingComponent() - } - ) - .align(Align.FILL) - .resizableColumn() - } - .resizableRow() - } - - private fun createEncodedComponent(): JComponent = panel { - row { - cell(encodedEditor.component) - .validationOnApply(encodedEditor.bindValidator(jwt.encodedErrorHolder.asValidation())) - .validationRequestor(DUMMY_DIALOG_VALIDATION_REQUESTOR) - .align(Align.FILL) - .resizableColumn() - } - .resizableRow() - .bottomGap(BottomGap.NONE) - val signatureErrors = jwt.signatureErrorHolder.asComponentPredicate() - row { - text(" Valid signature") - .visibleIf(signatureErrors.not()) - .resizableColumn() - text("") - .bindText(jwt.signatureErrorHolder.asPropertyForTextCell()) - .visibleIf(signatureErrors) - .resizableColumn() - } - .topGap(TopGap.NONE) - } - - @Suppress("UnstableApiUsage") - private fun createEncodingDecodingComponent(): JComponent = panel { - row { - val liveConversionCheckBox = - checkBox("Live conversion").bindSelected(liveConversion).gap(RightGap.SMALL) - - button("▼ Decode") { convert(ENCODED) } - .enabledIf(liveConversionCheckBox.selected.not()) - .gap(RightGap.SMALL) - button("▲ Encode") { - // This will set the signature algorithm in the header and will run the encoding. - convert(SIGNATURE_CONFIGURATION) - } - .enabledIf(liveConversionCheckBox.selected.not()) - } - - if (context.prioritizeVerticalLayout) { - row { - cell( - Splitter(true, 0.3f).apply { - firstComponent = createHeaderEditorComponent() - secondComponent = createPayloadEditorComponent() - } - ) - .align(Align.FILL) - .resizableColumn() - } - .resizableRow() - .bottomGap(BottomGap.NONE) - } else { - row { - cell( - Splitter(false, 0.5f).apply { - firstComponent = createHeaderEditorComponent() - secondComponent = createPayloadEditorComponent() - } - ) - .align(Align.FILL) - .resizableColumn() - } - .resizableRow() - .bottomGap(BottomGap.NONE) - } - - collapsibleGroup("Signature Algorithm Configuration") { - lateinit var signatureAlgorithmComboBox: ComboBox - row { - signatureAlgorithmComboBox = - comboBox(SignatureAlgorithm.entries) - .label("Algorithm:") - .bindItem(jwt.signature.algorithm) - .whenItemSelectedFromUi { convertFromUi(SIGNATURE_CONFIGURATION) } - .component - } - .layout(RowLayout.PARENT_GRID) - .topGap(TopGap.NONE) - - row { - // Bug: The label from `expandableTextField().label(...)` disappears - // if the encoding selection gets changed - label("Secret key:") - expandableTextField() - .align(AlignX.FILL) - .bindText(jwt.signature.secret) - .whenTextChangedFromUi { convertFromUi(SIGNATURE_CONFIGURATION) } - .gap(RightGap.SMALL) - .resizableColumn() - .registerDynamicToolTip { generalSettings.createSensitiveInputsHandlingToolTipText() } - - val encodingActions = - mutableListOf().apply { - SecretKeyEncodingMode.entries.forEach { secretKeyEncodingModeValue -> - add( - SimpleToggleAction( - text = secretKeyEncodingModeValue.title, - icon = AllIcons.Actions.ToggleSoftWrap, - isSelected = { - jwt.signature.secretEncodingMode.get() == secretKeyEncodingModeValue - }, - setSelected = { - jwt.signature.secretEncodingMode.set(secretKeyEncodingModeValue) - }, - ) - ) - } - } - actionButton( - UiUtils.actionsPopup( - title = "Encoding", - icon = AllIcons.General.Settings, - actions = encodingActions, - ) - ) - } - .visibleIf(ComboBoxPredicate(signatureAlgorithmComboBox) { it?.kind == HMAC }) - .layout(RowLayout.PARENT_GRID) - - row { - textArea() - .rows(5) - .align(Align.FILL) - .label(label = "Private key:", position = LabelPosition.TOP) - .bindText(jwt.signature.privateKey) - .setValidationResultBorder() - .whenTextChangedFromUi { convertFromUi(SIGNATURE_CONFIGURATION) } - .validationInfo(jwt.signature.privateKeyErrorHolder.asValidation()) - .registerDynamicToolTip { generalSettings.createSensitiveInputsHandlingToolTipText() } - } - .visibleIf(ComboBoxPredicate(signatureAlgorithmComboBox) { it?.kind?.keyFactory != null }) - - row { - checkBox("Strict key requirements validation") - .bindSelected(jwt.signature.strictSigningKeyValidation) - .whenStateChangedFromUi { convertFromUi(SIGNATURE_CONFIGURATION) } - .gap(RightGap.SMALL) - contextHelp( - "The RFC 7518 for the JSON Web Algorithms (JWA) specifies some restrictions that a key or secret should fulfill for the computation of a signature (e.g., a minimum length). This option can be used to enforce these restrictions." - ) - } - .visibleIf(ComboBoxPredicate(signatureAlgorithmComboBox) { it?.kind != NONE_KIND }) - } - .apply { expanded = false } - .topGap(TopGap.NONE) - } - - private fun createPayloadEditorComponent(): JComponent = panel { - row { - cell(payloadEditor.component) - .validationOnApply(payloadEditor.bindValidator(jwt.payloadErrorHolder.asValidation())) - .validationRequestor(DUMMY_DIALOG_VALIDATION_REQUESTOR) - .align(Align.FILL) - .resizableColumn() - } - .resizableRow() - } - - private fun createHeaderEditorComponent(): JComponent = panel { - row { - cell(headerEditor.component) - .validationOnApply(headerEditor.bindValidator(jwt.headerErrorHolder.asValidation())) - .validationRequestor(DUMMY_DIALOG_VALIDATION_REQUESTOR) - .align(Align.FILL) - .resizableColumn() - } - .resizableRow() - } - - override fun afterBuildUi() { - convertFromUi(ENCODED) - } - - override fun reset() { - convert(ENCODED) - } - - // -- Private Methods ----------------------------------------------------- // - - private fun convertFromUi(changeOrigin: ChangeOrigin) { - if (!liveConversion.get()) { - return - } - - convert(changeOrigin) - } - - private fun convert(changeOrigin: ChangeOrigin) { - if (configuration.isResetting) { - return - } - - if (!isDisposed && !conversionAlarm.isDisposed) { - conversionAlarm.cancelAllRequests() - conversionAlarm.addRequest({ doConvert(changeOrigin) }, 100) - } - } - - private fun doConvert(changeOrigin: ChangeOrigin) { - when (changeOrigin) { - ENCODED -> jwt.decodeJwt() - HEADER_OR_PAYLOAD -> jwt.encodeJwt() - SIGNATURE_CONFIGURATION -> { - jwt.setAlgorithmInHeader() - jwt.encodeJwt() - } - } - - highlightDotSeparator() - highlightHeaderClaims() - highlightPayloadClaims() - - // The `validate` in this class is not used as a validation mechanism. We - // make use of its text field error UI to display the `errorHolder`. - validate() - } - - private fun highlightDotSeparator() { - val highlightDotSeparator = { - encodedEditor.removeTextRangeHighlighters(ENCODED_DOT_SEPARATOR_GROUP_ID) - val encoded = encodedText.get() - var dotIndex = encoded.indexOf('.') - var i = 0 - while (dotIndex != -1) { - encodedEditor.highlightTextRange( - TextRange(dotIndex, dotIndex + 1), - ENCODED_DOT_SEPARATOR_HIGHLIGHTER_LAYER, - highlightingAttributes, - ENCODED_DOT_SEPARATOR_GROUP_ID, - ) - dotIndex = encoded.indexOf('.', dotIndex + 1) - i++ - } - } - if (!isDisposed && !highlightEncodedAlarm.isDisposed) { - highlightEncodedAlarm.cancelAllRequests() - highlightEncodedAlarm.addRequest(highlightDotSeparator, 100) - } - } - - private fun highlightHeaderClaims() { - if (!isDisposed && !highlightHeaderAlarm.isDisposed) { - highlightHeaderAlarm.cancelAllRequests() - highlightHeaderAlarm.addRequest({ doHighlightClaims(headerEditor) }, 100) - } - } - - private fun highlightPayloadClaims() { - if (!isDisposed && !highlightPayloadAlarm.isDisposed) { - highlightPayloadAlarm.cancelAllRequests() - highlightPayloadAlarm.addRequest({ doHighlightClaims(payloadEditor) }, 100) - } - } - - private fun doHighlightClaims(editor: AdvancedEditor) { - editor.removeTextRangeHighlighters(HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID) - - UNIX_TIMESTAMP_SECONDS_JSON_VALUE_REGEX.findAll(editor.text).forEach { - val unixTimestampSecondsMatch = it.groups[1] - if (unixTimestampSecondsMatch != null) { - val textRange = - TextRange(unixTimestampSecondsMatch.range.first, unixTimestampSecondsMatch.range.last + 1) - editor.highlightTextRange( - textRange, - UNIX_TIMESTAMP_HIGHLIGHT_LAYER, - null, - HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID, - ClaimFeatureUnixTimestampGutterIconRenderer( - textRange, - unixTimestampSecondsMatch.value.toLong(), - ), - ) - } - } - - CLAIM_REGEX.findAll(editor.text) - .mapNotNull { - val claimMatch = it.groups[1] - if (claimMatch != null) { - val standardClaim = StandardClaim.findByFieldName(claimMatch.value) - if (standardClaim != null) { - return@mapNotNull claimMatch.range to standardClaim - } - } - null - } - .forEach { (claimRange, standardClaim) -> - val textRange = TextRange(claimRange.first, claimRange.last + 1) - editor.highlightTextRange( - textRange, - CLAIM_REGEX_MATCH_HIGHLIGHT_LAYER, - null, - HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID, - StandardClaimGutterIconRenderer(textRange, standardClaim), - ) - } - } - - private fun createEncodedEditor(): AdvancedEditor = - createEditor( - id = "encoded", - changeOrigin = ENCODED, - title = "Encoded", - language = PlainTextLanguage.INSTANCE, - textProperty = encodedText, - ) { - highlightDotSeparator() - } - - private fun createHeaderEditor(): AdvancedEditor = - createEditor( - id = "header", - changeOrigin = HEADER_OR_PAYLOAD, - title = "Header", - language = JsonLanguage.INSTANCE, - textProperty = headerText, - ) { - highlightHeaderClaims() - } - - private fun createPayloadEditor(): AdvancedEditor = - createEditor( - id = "payload", - changeOrigin = HEADER_OR_PAYLOAD, - title = "Payload", - language = JsonLanguage.INSTANCE, - textProperty = payloadText, - ) { - highlightPayloadClaims() - } - - private fun createEditor( - id: String, - changeOrigin: ChangeOrigin, - title: String, - language: Language, - textProperty: ValueProperty, - onTextChangeFromUi: (() -> Unit)? = null, - ) = - AdvancedEditor( - id = id, - context = context, - configuration = configuration, - project = project, - title = title, - editorMode = EditorMode.INPUT_OUTPUT, - parentDisposable = parentDisposable, - textProperty = textProperty, - initialLanguage = language, - ) - .apply { - onFocusGained { lastActiveInput = this } - this.onTextChangeFromUi { _ -> - lastActiveInput = this - convertFromUi(changeOrigin) - onTextChangeFromUi?.invoke() - } - } - - private fun handleLiveConversionSwitch() { - if (liveConversion.get()) { - // Trigger a text change. So if the text was changed in manual mode, it - // will now be encoded/decoded once during the switch to live mode. - when (lastActiveInput) { - encodedEditor -> convert(ENCODED) - headerEditor, - payloadEditor -> { - // This will set the signature algorithm in the header and will run the encoding. - convert(SIGNATURE_CONFIGURATION) - } - - null -> {} - } - } - } - - // -- Inner Type ---------------------------------------------------------- // - - private class StandardClaimGutterIconRenderer( - private val textRange: TextRange, - private val standardClaim: StandardClaim, - ) : GutterIconRenderer(), DumbAware { - - override fun getTooltipText(): String = standardClaim.toString() - - override fun getIcon(): Icon = AllIcons.Gutter.JavadocRead - - override fun equals(other: Any?): Boolean { - return if (other != null && other is StandardClaimGutterIconRenderer) { - other.textRange == textRange && other.standardClaim == standardClaim - } else { - false - } - } - - override fun hashCode(): Int = Objects.hash(textRange, standardClaim) - - override fun getAlignment(): Alignment = Alignment.RIGHT - } - - // -- Inner Type ---------------------------------------------------------- // - - private class ClaimFeatureUnixTimestampGutterIconRenderer( - private val textRange: TextRange, - private val unixTimestampSeconds: Long, - ) : GutterIconRenderer(), DumbAware { - - override fun getTooltipText(): String { - val tooltipText = StringJoiner("

") - - tooltipText.add( - Instant.ofEpochSecond(unixTimestampSeconds) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ISO_ZONED_DATE_TIME) - ) - - val diff = - DateFormatUtil.formatBetweenDates( - unixTimestampSeconds.times(1000), - System.currentTimeMillis(), - ) - tooltipText.add("${diff.capitalize()}.") - - return tooltipText.toString() - } - - override fun getIcon(): Icon = clockGutterIcon - - override fun equals(other: Any?): Boolean { - return if (other != null && other is ClaimFeatureUnixTimestampGutterIconRenderer) { - other.textRange == textRange && other.unixTimestampSeconds == unixTimestampSeconds - } else { - false - } - } - - override fun hashCode(): Int = Objects.hash(textRange, unixTimestampSeconds) - - override fun getAlignment(): Alignment = Alignment.LEFT - - companion object { - - private val clockGutterIcon = - IconManager.getInstance() - .getIcon( - "dev/turingcomplete/intellijdevelopertoolsplugin/icons/clock_gutter.svg", - JwtEncoderDecoder::class.java.classLoader, - ) - } - } - - // -- Inner Type ---------------------------------------------------------- // - - private enum class ChangeOrigin { - - ENCODED, - HEADER_OR_PAYLOAD, - SIGNATURE_CONFIGURATION, - } - - // -- Inner Type ---------------------------------------------------------- // - - enum class SignatureAlgorithmKind(val keyFactory: KeyFactory?) { - - NONE(null), - HMAC(null), - RSA(KeyFactory.getInstance("RSA")), - ECDSA(KeyFactory.getInstance("EC")), - } - - // -- Inner Type ---------------------------------------------------------- // - - enum class SignatureAlgorithm( - val jwtHeaderValue: String, - val kind: SignatureAlgorithmKind, - @Suppress("unused") // May be used for JWK validation - val algorithmIdentifiers: String, - ) { - - NONE("none", NONE_KIND, "none"), - HMAC256("HS256", HMAC, HMAC_SHA256), - HMAC384("HS384", HMAC, HMAC_SHA384), - HMAC512("HS512", HMAC, HMAC_SHA512), - RSA256("RS256", RSA, RSA_USING_SHA256), - RSA384("RS384", RSA, RSA_USING_SHA384), - RSA512("RS512", RSA, RSA_USING_SHA512), - ECDSA256("ES256", ECDSA, ECDSA_USING_P256_CURVE_AND_SHA256), - ECDSA384("ES384", ECDSA, ECDSA_USING_P384_CURVE_AND_SHA384), - ECDSA512("ES512", ECDSA, ECDSA_USING_P521_CURVE_AND_SHA512); - - override fun toString(): String = - if (this == NONE) { - "None" - } else { - "$name ($jwtHeaderValue)" - } - - companion object { - - fun findByJwtHeaderValue(jwtHeaderValue: String): SignatureAlgorithm? = - entries.firstOrNull { it.jwtHeaderValue == jwtHeaderValue } - } - } - - // -- Inner Type ---------------------------------------------------------- // - - private class Jwt( - configuration: DeveloperToolConfiguration, - val encoded: ValueProperty, - val header: ValueProperty, - val payload: ValueProperty, - ) { - - val encodedErrorHolder = ErrorHolder() - val headerErrorHolder = ErrorHolder() - val payloadErrorHolder = ErrorHolder() - val signatureErrorHolder = - ErrorHolder(addErrorIconToMessage = true, surroundMessageWithHtml = false) - - val signature = Signature(configuration, signatureErrorHolder) - - fun decodeJwt() { - clearErrorHolders() - - val jwtParts = encoded.get().split('.', limit = 3) - val numOfJwtParts = jwtParts.size - - // Header - if (numOfJwtParts >= 1) { - val handleError: (Exception) -> Unit = { error -> - header.set(jwtParts[0]) - headerErrorHolder.add(error) - } - parseAsJson(text = jwtParts[0], textIsBase64 = true, handleError) { - parseHeader(it) - header.set(ObjectMapperService.instance.prettyPrintJson(it)) - } - } else { - header.set("") - } - - // Payload - if (numOfJwtParts >= 2) { - val handleError: (Exception) -> Unit = { error -> - payload.set(jwtParts[1]) - payloadErrorHolder.add(error) - } - parseAsJson(text = jwtParts[1], textIsBase64 = true, handleError) { - payload.set(ObjectMapperService.instance.prettyPrintJson(it)) - } - } else { - payload.set("") - } - - // Signature - if (headerErrorHolder.isNotSet()) { - val actualSignature = jwtParts.getOrElse(2) { "" } - if (signature.algorithm.get() == NONE) { - if (actualSignature.isNotEmpty()) { - signatureErrorHolder.add("JWT with algorithm 'none' must not contain a signature") - } - } else if (numOfJwtParts >= 3) { - signature.compute(jwtParts[0], jwtParts[1])?.let { expectedSignature -> - if (expectedSignature != actualSignature) { - signatureErrorHolder.add( - "Invalid signature. Check the configuration in the 'Signature Algorithm Configuration' section." - ) - } - } - } else { - signatureErrorHolder.add("Encoded JWT does not have a signature part") - } - } - } - - fun encodeJwt() { - clearErrorHolders() - - val jsonMapper = ObjectMapperService.instance.jsonMapper() - - val headerJson = - try { - val headerJson = jsonMapper.readTree(header.get()) - parseHeader(headerJson) - headerJson - } catch (e: Exception) { - headerErrorHolder.add(e) - null - } - - val payloadJson = - try { - jsonMapper.readTree(payload.get()) - } catch (e: Exception) { - payloadErrorHolder.add(e) - null - } - - if (headerErrorHolder.isSet() || payloadErrorHolder.isSet()) { - encoded.set("") - signatureErrorHolder.add("Unable to compute signature due to header or payload errors") - } else { - val encodedHeader = - urlEncoder - .encode(jsonMapper.writeValueAsString(headerJson!!).encodeToByteArray()) - .decodeToString() - val encodedPayload = - urlEncoder - .encode(jsonMapper.writeValueAsString(payloadJson!!).encodeToByteArray()) - .decodeToString() - val encodedSignature = signature.compute(encodedHeader, encodedPayload) - if (encodedSignature == null) { - encoded.set("") - signatureErrorHolder.addIfNoErrors( - "Unable to compute signature due signature configuration errors" - ) - } else { - encoded.set("${encodedHeader}.${encodedPayload}.$encodedSignature") - } - } - } - - fun setAlgorithmInHeader() { - parseAsJson(text = header.get(), textIsBase64 = false, { headerErrorHolder.add(it) }) { - headerNode -> - if (headerNode is ObjectNode) { - headerNode.put("alg", signature.algorithm.get().jwtHeaderValue) - header.set(ObjectMapperService.instance.prettyPrintJson(headerNode)) - } - } - } - - private fun clearErrorHolders() { - encodedErrorHolder.clear() - headerErrorHolder.clear() - payloadErrorHolder.clear() - signatureErrorHolder.clear() - } - - private fun parseHeader(headerNode: JsonNode) { - if (headerNode.has("alg")) { - val algFieldValue = headerNode.get("alg").asText() - val algorithm = SignatureAlgorithm.findByJwtHeaderValue(algFieldValue) - if (algorithm != null) { - signature.algorithm.set(algorithm) - } else { - headerErrorHolder.add("Unsupported algorithm: '$algFieldValue'") - } - } else { - headerErrorHolder.add("Missing algorithm header field: 'alg'") - } - } - - private fun parseAsJson( - text: String, - textIsBase64: Boolean, - handleError: (Exception) -> Unit, - handleResult: (JsonNode) -> Unit, - ) { - try { - val actualText = if (textIsBase64) text.decodeBase64String() else text - val jsonNode = ObjectMapperService.instance.jsonMapper().readTree(actualText) - handleResult(jsonNode) - } catch (e: Exception) { - handleError(e) - } - } - } - - // -- Inner Type ---------------------------------------------------------- // - - private class Signature( - configuration: DeveloperToolConfiguration, - private val signatureErrorHolder: ErrorHolder, - ) { - - val algorithm = configuration.register("algorithm", DEFAULT_SIGNATURE_ALGORITHM) - val strictSigningKeyValidation = - configuration.register("signingKeyValidation", SIGNING_KEY_VALIDATION_DEFAULT, CONFIGURATION) - val secret = configuration.register("secret", "", SENSITIVE, EXAMPLE_SECRET) - val privateKey = - configuration.registerWithExampleProvider("privateKey", "", SENSITIVE) { - if (algorithm.get().kind == RSA) EXAMPLE_RSA_PRIVATE_KEY else EXAMPLE_EC_PRIVATE_KEY - } - val secretEncodingMode = configuration.register("secretKeyEncodingMode", RAW, CONFIGURATION) - - val privateKeyErrorHolder = ErrorHolder() - - init { - handleAlgorithmChange() - algorithm.afterChangeConsumeEvent(null) { e -> - if (e.valueChanged()) { - handleAlgorithmChange() - } - } - } - - fun compute(encodedHeader: String, encodedPayload: String): String? { - privateKeyErrorHolder.clear() - - return try { - if (algorithm.get() == NONE) { - return "" - } - val signingKey = createSigningKey() ?: return null - ExtendedJsonWebSignature() - .apply { - // The algorithm gets derivative from the header property `alg` - setEncodedHeader(encodedHeader) - setEncodedPayload(encodedPayload) - setKey(signingKey) - isDoKeyValidation = strictSigningKeyValidation.get() - sign() - } - .encodedSignature - } catch (e: Exception) { - signatureErrorHolder.add("Failed to compute signature:", ExceptionUtil.getRootCause(e)) - null - } - } - - private fun createSigningKey(): Key? { - val signatureAlgorithm = algorithm.get() - return when (signatureAlgorithm.kind) { - NONE_KIND -> null - HMAC -> - HmacKey( - when (secretEncodingMode.get()) { - RAW -> secret.get().encodeToByteArray() - BASE32 -> Base32().decode(secret.get()) - BASE64 -> Base64.getDecoder().decode(secret.get()) - } - ) - - RSA, - ECDSA -> readPrivateKey(signatureAlgorithm.kind.keyFactory!!) ?: return null - } - } - - private fun readPrivateKey(keyFactory: KeyFactory) = - try { - val privateKeyValue = privateKey.get() - if (privateKeyValue.isBlank()) { - privateKeyErrorHolder.add("A private key must be provided") - null - } else { - keyFactory.generatePrivate(PKCS8EncodedKeySpec(toRawKey(privateKey.get()))) - } - } catch (e: Exception) { - privateKeyErrorHolder.add(e) - null - } - - private fun toRawKey(keyInput: String): ByteArray = - Base64.getDecoder().decode(keyInput.replace(RAW_KEY_REGEX, "")) - - private fun handleAlgorithmChange() { - if (generalSettings.loadExamples.get()) { - loadExampleSecrets() - } - } - - private fun loadExampleSecrets() { - val privateKeyValue = privateKey.get() - when (algorithm.get().kind) { - NONE_KIND -> {} - HMAC -> { - if (secret.get().isBlank()) { - secret.set(EXAMPLE_SECRET) - } - } - - RSA -> { - if (privateKeyValue.isBlank() || privateKeyValue == EXAMPLE_EC_PRIVATE_KEY) { - privateKey.set(EXAMPLE_RSA_PRIVATE_KEY) - } - } - - ECDSA -> { - if (privateKeyValue.isBlank() || privateKeyValue == EXAMPLE_RSA_PRIVATE_KEY) { - privateKey.set(EXAMPLE_EC_PRIVATE_KEY) - } - } - } - } - } - - // -- Inner Type ---------------------------------------------------------- // - - private enum class StandardClaim( - val fieldName: String, - val title: String, - val description: String, - ) { - - TYP(fieldName = "typ", title = "Type", description = "Indicating that this token is a JWT."), - ISSUER(fieldName = "iss", title = "Issuer", description = "The issuer of the JWT."), - SUBJECT(fieldName = "sub", title = "Subject", description = "The subject of the JWT."), - AUDIENCE(fieldName = "aud", title = "Audience", description = "The recipient of the JWT."), - EXPIRATION_TIME( - fieldName = "exp", - title = "Expiration Time", - description = "JWT expiration time.", - ), - NOT_BEFORE_TIME( - fieldName = "nbf", - title = "Not Before Time", - description = "JWT valid after this time.", - ), - ISSUED_AT_TIME( - fieldName = "iat", - title = "Issued at Time", - description = "JWT issued at this time.", - ), - JWT_ID(fieldName = "jti", title = "JWT ID", description = "A unique identifier for the JWT."), - ALG( - fieldName = "alg", - title = "Algorithm", - description = "The algorithm to calculate the signature of this JWT.", - ), - AZP( - fieldName = "azp", - title = "Authorized Party", - description = "The party to which the JWT was issued.", - ), - SID(fieldName = "sid", title = "Session ID", description = "An unique session ID."), - NONCE( - fieldName = "nonce", - title = "Nonce", - description = "A value used to associate a client session with this JWT.", - ), - AT_HASH( - fieldName = "at_hash", - title = "Access Token Hash Value", - description = "The hash of an access token.", - ), - C_HASH(fieldName = "c_hash", title = "Code Hash Value", description = "The hash of a code."), - ACT(fieldName = "act", title = "Actor", description = "The has of an access token."), - AUTH_TIME( - fieldName = "auth_time", - title = "Authentication Time", - description = "Time of user authentication.", - ), - SCOPE(fieldName = "scope", title = "Scope", description = "Permissions granted to the token."); - - override fun toString(): String = "$title ($fieldName)
$description" - - companion object { - - fun findByFieldName(fieldName: String): StandardClaim? = - entries.firstOrNull { it.fieldName == fieldName } - } - } - - // -- Inner Type ---------------------------------------------------------- // - - class Factory : DeveloperUiToolFactory { - - override fun getDeveloperUiToolPresentation() = - DeveloperUiToolPresentation( - menuTitle = "JSON Web Token (JWT)", - contentTitle = "JSON Web Token (JWT) Decoder/Encoder", - ) - - override fun getDeveloperUiToolCreator( - project: Project?, - parentDisposable: Disposable, - context: DeveloperUiToolContext, - ): ((DeveloperToolConfiguration) -> JwtEncoderDecoder) = { configuration -> - JwtEncoderDecoder(context, configuration, parentDisposable, project) - } - } - - // -- Inner Type ---------------------------------------------------------- // - - // -- Inner Type ---------------------------------------------------------- // - - private class ExtendedJsonWebSignature : JsonWebSignature() { - - @Suppress("RedundantVisibilityModifier") // False-positive - public override fun setEncodedHeader(encodedHeader: String?) { - super.setEncodedHeader(encodedHeader) - } - } - - // -- Inner Type ---------------------------------------------------------- // - - enum class SecretKeyEncodingMode(val title: String) { - - RAW("Raw"), - BASE32("Base32 Encoded"), - BASE64("Base64 Encoded"), - } - - // -- Companion Object ---------------------------------------------------- // - - companion object { - - private val urlEncoder = Base64.getUrlEncoder().withoutPadding() - - private val UNIX_TIMESTAMP_SECONDS_JSON_VALUE_REGEX = - Regex(":\\s*(?\\b\\d{1,10}\\b)") - private val CLAIM_REGEX = Regex("\\s?\"(?[a-zA-Z]+)\"\\s?:") - private const val UNIX_TIMESTAMP_HIGHLIGHT_LAYER = HighlighterLayer.SELECTION - 1 - private const val CLAIM_REGEX_MATCH_HIGHLIGHT_LAYER = UNIX_TIMESTAMP_HIGHLIGHT_LAYER - 1 - - private const val HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID = "claims" - private const val ENCODED_DOT_SEPARATOR_GROUP_ID = "encodedDotSeparator" - private const val ENCODED_DOT_SEPARATOR_HIGHLIGHTER_LAYER = HighlighterLayer.SELECTION - 1 - - private val RAW_KEY_REGEX = Regex("\\r?\\n|\\r|\\s?-+(BEGIN|END).*KEY-+\\s?") - - private val DEFAULT_SIGNATURE_ALGORITHM = HMAC256 - private const val EXAMPLE_ENCODED = - "ewogICJ0eXAiOiJKV1QiLAogICJhbGciOiJIUzI1NiIKfQ.ewogICJqdGkiOiI5NjQ5MmQ1OS0wYWQ1LTRjMDAtODkyZC01OTBhZDVhYzAwZjMiLAogICJzdWIiOiIwMTIzNDU2Nzg5IiwKICAibmFtZSI6IkpvaG4gRG9lIiwKICAiaWF0IjoxNjgxMDQwNTE1Cn0.IqeNl3lHSUfPfEYmttvlQp1sH9LpAoPJlUiSv4XPDSE" - private const val EXAMPLE_SECRET = "s3cre!" - private val EXAMPLE_HEADER = - """ - { - "typ":"JWT", - "alg":"HS256" - } - """ - .trimIndent() - private val EXAMPLE_PAYLOAD = - """ - { - "jti":"96492d59-0ad5-4c00-892d-590ad5ac00f3", - "sub":"0123456789", - "name":"John Doe", - "iat":1681040515 - } - """ - .trimIndent() - - private const val SIGNING_KEY_VALIDATION_DEFAULT = false - - private val EXAMPLE_RSA_PRIVATE_KEY = - """ ------BEGIN RSA PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDdadLFj3DqaYtpZ1ik6ejpIIAU -2KhFqygTvR6SSS9RmcFQu/vojHWzQUhm8aqrGYVkDXCHvEcyBPcZUlWBczcDwQ5YF8VktRpMxfAI -K/OZRmfrhK9jAZsxOPCCXMOY+JoCbEqEOpsClbHKbgNBgw4AfsISzuWODa47KucIQad202lUZMQ5 -iBQ9CRcSfSis6HyvCMTY5li/9a+O78FfqIGUE4FHeJpsiay2z2AMEzwBPoURkTaSjjOT25e+GY7k -ntilnVne1ORdOPMnOcd28COex55Z+C4QlOr2UIDaAinTAG/0ozwWxd8OaVJJy3mj3dd3AeD2vBMm -ycnhrM+sccqHAgMBAAECggEARelztZDg2QuhixMoUM5RDkeGWc69d14fZfgpzowQRmZTvZ/V32x2 -f7bl2yeEucjxrxF1Tk67dkZOFa9DM4BDR0qusk8zM2Th3IsFizcBkIzEJIA9dvgbXjP58VfEJSme -S5SRBOaSaoME5APPwGBWy/46XoD4x912/dTCpX9Blwl81i7EO8o3NnYhsCWeVoUJTWBzN95OchZF -ozV4pFgv0tZqTNa7VhJtWHHiKkCpdK7gA9SeVEqEeL1TADAa2ngy3BIRfTgdAct6/4N+ZlVsaIXB -1Gnw2RaOoUbHy1PCA6ygtH5lz65p0JdWGcO5l+JNeYmOIeOdJ3QWbVI+CikPGQKBgQDv/JrTewDt -BK5KBpotFsrJeDFKOkC6A8aNeGliAEgJYvCk7zb8RtKCx7ViaYGYWJYj30oYejYEE+vFT7sgzXfE -JPAEiMw4uKeIrbX8QEIP+R25S8iRr657DkTxOvyhO2oQcC7UkZagvrVyQ17VgjtjxGWbc5bRBk5v -u1ZV9VMZuQKBgQDsL/PsLCX3YRBB5+0rpWoTKKrmFtGh31oue+d37Nd7oxBzb2uyF4Q29+zoy1on -EnHNamjjdR95NZoOjEsIIKDTV1C/bsS7be53m0mwKQfecKIXJ+7VN4UsYZXjCajHCr3NFHiIU8ct -pcKGtg7ga5cERIBtrPAi9Qzi7/o1MxUmPwKBgFuSaMWPZuAJ7DNE56mSy9gqa6xmI/KWpDmxG40Q -jGxAe5CD0thacdMDPzwJBDFMhCW1+wDyCRBvRYSpkr7GiA+pBIjGZh6ynwKxPgK9xjdwGB5vQ14L -yikcXcQqfOFM2YDiPYxQ7Ufy3St3d4VCx0SfWSIC7iZeIKnTsvLjxEzJAoGBAKcLFzou0z9N3+Cs -9pnK6OXZ+ly3QNZ6kF6V9VRlJtXjs0vhPsr7ROBXoq/WutEtg11j6AEPIg5o8adeY+bApN40QADU -h8GD84eWRZyYuF8DTDCSZqFYHhEQh6DGgR8dIrX7x2+ryRAozxbVhloE3g7/n9Fx4Xjn1ZBfZ5fe -pBOjAoGAcw2M22BK3NWOHhJ8EC4p6aUIR96lNcCWE/ij+MWCcRdotLDSDuT1q13C+UTxDZ5PsmDs -N/bhCDRZYZoLYo0/h6v4zKBDaX05nVUTCYux0Fo2HGrj5S0bjmgyRcr8+enA3CTzCHZPWZ7ZeADb -0Mbtt/Q4JyOCgwORgXJVQBHxxIQ= ------END RSA PRIVATE KEY----- - """ - .trimIndent() - private val EXAMPLE_EC_PRIVATE_KEY = - """ ------BEGIN EC PRIVATE KEY----- -MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCDQ+B6qEzr/M2sql4X+09X9YlYt8BKA -HX8Q7/6s4KC3qQ== ------END RSA PRIVATE KEY----- - """ - .trimIndent() - } -} diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEditorHighlighter.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEditorHighlighter.kt new file mode 100644 index 0000000..030623e --- /dev/null +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEditorHighlighter.kt @@ -0,0 +1,203 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.jwtencoderdecoder + +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.markup.GutterIconRenderer +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.util.TextRange +import com.intellij.ui.IconManager +import com.intellij.util.Alarm +import com.intellij.util.text.DateFormatUtil +import dev.turingcomplete.intellijdevelopertoolsplugin.common.ValueProperty +import dev.turingcomplete.intellijdevelopertoolsplugin.common.capitalize +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Objects +import java.util.StringJoiner +import javax.swing.Icon + +internal class JwtEditorHighlighter( + parentDisposable: Disposable, + private val encodedText: ValueProperty, + private val highlightingAttributes: TextAttributes?, +) { + + private val highlightEncodedAlarm = Alarm(parentDisposable) + private val highlightHeaderAlarm = Alarm(parentDisposable) + private val highlightPayloadAlarm = Alarm(parentDisposable) + + fun refresh( + encodedEditor: AdvancedEditor, + headerEditor: AdvancedEditor, + payloadEditor: AdvancedEditor, + isToolDisposed: Boolean, + ) { + scheduleDotSeparatorHighlight(encodedEditor, isToolDisposed) + scheduleHeaderClaimsHighlight(headerEditor, isToolDisposed) + schedulePayloadClaimsHighlight(payloadEditor, isToolDisposed) + } + + fun scheduleDotSeparatorHighlight(editor: AdvancedEditor, isToolDisposed: Boolean) { + if (!isToolDisposed && !highlightEncodedAlarm.isDisposed) { + highlightEncodedAlarm.cancelAllRequests() + highlightEncodedAlarm.addRequest({ highlightDotSeparatorsNow(editor) }, 100) + } + } + + fun highlightDotSeparatorsNow(editor: AdvancedEditor) { + editor.removeTextRangeHighlighters(ENCODED_DOT_SEPARATOR_GROUP_ID) + val encoded = encodedText.get() + var dotIndex = encoded.indexOf('.') + while (dotIndex != -1) { + editor.highlightTextRange( + TextRange(dotIndex, dotIndex + 1), + ENCODED_DOT_SEPARATOR_HIGHLIGHTER_LAYER, + highlightingAttributes, + ENCODED_DOT_SEPARATOR_GROUP_ID, + ) + dotIndex = encoded.indexOf('.', dotIndex + 1) + } + } + + fun scheduleHeaderClaimsHighlight(editor: AdvancedEditor, isToolDisposed: Boolean) { + if (!isToolDisposed && !highlightHeaderAlarm.isDisposed) { + highlightHeaderAlarm.cancelAllRequests() + highlightHeaderAlarm.addRequest({ highlightClaims(editor) }, 100) + } + } + + fun schedulePayloadClaimsHighlight(editor: AdvancedEditor, isToolDisposed: Boolean) { + if (!isToolDisposed && !highlightPayloadAlarm.isDisposed) { + highlightPayloadAlarm.cancelAllRequests() + highlightPayloadAlarm.addRequest({ highlightClaims(editor) }, 100) + } + } + + private fun highlightClaims(editor: AdvancedEditor) { + editor.removeTextRangeHighlighters(HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID) + + JwtEncoderDecoder.unixTimestampSecondsJsonValueRegex.findAll(editor.text).forEach { + val unixTimestampSecondsMatch = it.groups[1] + if (unixTimestampSecondsMatch != null) { + val textRange = + TextRange(unixTimestampSecondsMatch.range.first, unixTimestampSecondsMatch.range.last + 1) + editor.highlightTextRange( + textRange, + UNIX_TIMESTAMP_HIGHLIGHT_LAYER, + null, + HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID, + ClaimFeatureUnixTimestampGutterIconRenderer( + textRange, + unixTimestampSecondsMatch.value.toLong(), + ), + ) + } + } + + JwtEncoderDecoder.claimRegex + .findAll(editor.text) + .mapNotNull { + val claimMatch = it.groups[1] + if (claimMatch != null) { + val standardClaim = StandardClaim.findByFieldName(claimMatch.value) + if (standardClaim != null) { + return@mapNotNull claimMatch.range to standardClaim + } + } + null + } + .forEach { (claimRange, standardClaim) -> + val textRange = TextRange(claimRange.first, claimRange.last + 1) + editor.highlightTextRange( + textRange, + CLAIM_REGEX_MATCH_HIGHLIGHT_LAYER, + null, + HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID, + StandardClaimGutterIconRenderer(textRange, standardClaim), + ) + } + } + + companion object { + + private const val HEADER_PAYLOAD_HIGHLIGHT_GROUP_ID = "claims" + private const val ENCODED_DOT_SEPARATOR_GROUP_ID = "encodedDotSeparator" + private const val ENCODED_DOT_SEPARATOR_HIGHLIGHTER_LAYER = HighlighterLayer.SELECTION - 1 + private const val UNIX_TIMESTAMP_HIGHLIGHT_LAYER = HighlighterLayer.SELECTION - 1 + private const val CLAIM_REGEX_MATCH_HIGHLIGHT_LAYER = UNIX_TIMESTAMP_HIGHLIGHT_LAYER - 1 + } +} + +internal class StandardClaimGutterIconRenderer( + private val textRange: TextRange, + private val standardClaim: StandardClaim, +) : GutterIconRenderer() { + + override fun getTooltipText(): String = standardClaim.toString() + + override fun getIcon(): Icon = AllIcons.Gutter.JavadocRead + + override fun equals(other: Any?): Boolean { + return if (other != null && other is StandardClaimGutterIconRenderer) { + other.textRange == textRange && other.standardClaim == standardClaim + } else { + false + } + } + + override fun hashCode(): Int = Objects.hash(textRange, standardClaim) + + override fun getAlignment(): Alignment = Alignment.RIGHT +} + +internal class ClaimFeatureUnixTimestampGutterIconRenderer( + private val textRange: TextRange, + private val unixTimestampSeconds: Long, +) : GutterIconRenderer() { + + override fun getTooltipText(): String { + val tooltipText = StringJoiner("

") + + tooltipText.add( + Instant.ofEpochSecond(unixTimestampSeconds) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ISO_ZONED_DATE_TIME) + ) + + val diff = + DateFormatUtil.formatBetweenDates( + unixTimestampSeconds.times(1000), + System.currentTimeMillis(), + ) + tooltipText.add("${diff.capitalize()}.") + + return tooltipText.toString() + } + + override fun getIcon(): Icon = clockGutterIcon + + override fun equals(other: Any?): Boolean { + return if (other != null && other is ClaimFeatureUnixTimestampGutterIconRenderer) { + other.textRange == textRange && other.unixTimestampSeconds == unixTimestampSeconds + } else { + false + } + } + + override fun hashCode(): Int = Objects.hash(textRange, unixTimestampSeconds) + + override fun getAlignment(): Alignment = Alignment.LEFT + + companion object { + + private val clockGutterIcon = + IconManager.getInstance() + .getIcon( + "dev/turingcomplete/intellijdevelopertoolsplugin/icons/clock_gutter.svg", + ClaimFeatureUnixTimestampGutterIconRenderer::class.java.classLoader, + ) + } +} diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt new file mode 100644 index 0000000..554df5a --- /dev/null +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt @@ -0,0 +1,739 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.jwtencoderdecoder + +import com.intellij.icons.AllIcons +import com.intellij.json.JsonLanguage +import com.intellij.lang.Language +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.fileTypes.PlainTextLanguage +import com.intellij.openapi.observable.properties.AtomicProperty +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.getUserData +import com.intellij.openapi.ui.putUserData +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.Splitter +import com.intellij.ui.JBSplitter +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.LabelPosition +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.actionButton +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.rows +import com.intellij.ui.dsl.builder.selected +import com.intellij.ui.dsl.builder.whenItemSelectedFromUi +import com.intellij.ui.dsl.builder.whenStateChangedFromUi +import com.intellij.ui.dsl.builder.whenTextChangedFromUi +import com.intellij.ui.layout.ComboBoxPredicate +import com.intellij.ui.layout.not +import com.intellij.util.Alarm +import com.intellij.util.ui.JBUI +import dev.turingcomplete.intellijdevelopertoolsplugin.common.ValueProperty +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.INPUT +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolsApplicationSettings.Companion.generalSettings +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.GeneralSettings.Companion.createSensitiveInputsHandlingToolTipText +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiTool +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolContext +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolFactory +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolPresentation +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor.EditorMode +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.PropertyComponentPredicate +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.SimpleToggleAction +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.UiUtils +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.onSelectionChanged +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.registerDynamicToolTip +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.setValidationResultBorder +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.wrapTabbedPaneContent +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.message.UiToolsBundle +import java.util.Base64 +import javax.swing.JComponent + +class JwtEncoderDecoder( + private val context: DeveloperUiToolContext, + private val configuration: DeveloperToolConfiguration, + parentDisposable: Disposable, + private val project: Project?, +) : DeveloperUiTool(parentDisposable) { + // -- Properties ---------------------------------------------------------- // + + private var liveConversion = configuration.register("liveConversion", true) + private var encodedText = configuration.register("encodedText", "", INPUT, EXAMPLE_ENCODED) + private var headerText = configuration.register("headerText", "", INPUT, exampleHeader) + private var payloadText = configuration.register("payloadText", "", INPUT, examplePayload) + + private val conversionAlarm by lazy { Alarm(parentDisposable) } + private val validationAlarm by lazy { Alarm(parentDisposable) } + + private var selectedTab = JwtTab.DECODE_ENCODE + private val encodedEditorLabel = AtomicProperty(sharedEncodedEditorLabel(selectedTab)) + private var lastActiveInput: AdvancedEditor? = null + private val encodedEditor by lazy { createEncodedEditor() } + private val headerEditor by lazy { createHeaderEditor() } + private val payloadEditor by lazy { createPayloadEditor() } + + private val highlightingAttributes by lazy { + EditorColorsManager.getInstance() + .globalScheme + .getAttributes(EditorColors.SEARCH_RESULT_ATTRIBUTES) + } + + private val highlighter by lazy { + JwtEditorHighlighter(parentDisposable, encodedText, highlightingAttributes) + } + private val jwt = Jwt(configuration, encodedText, headerText, payloadText) + private val validation = JwtValidation(configuration) + + // -- Initialization ------------------------------------------------------ // + + init { + liveConversion.afterChange(parentDisposable) { handleLiveConversionSwitch() } + + jwt.signature.secretEncodingMode.afterChangeConsumeEvent(null) { event -> + if (event.valueChanged()) { + convertFromUi(ChangeOrigin.SIGNATURE_CONFIGURATION) + } + } + + validation.secretEncodingMode.afterChangeConsumeEvent(null) { event -> + if (event.valueChanged()) { + validateFromUi() + } + } + } + + // -- Exposed Methods ----------------------------------------------------- // + + override fun Panel.buildUi() { + row { + cell( + JBSplitter(true, 0.2f).apply { + firstComponent = createSharedEncodedComponent() + secondComponent = + JBTabbedPane().apply { + tabComponentInsets = JBUI.emptyInsets() + + addTab( + UiToolsBundle.message("jwt-encoder-decoder.tab.decode-encode"), + createTabContent(JwtTab.DECODE_ENCODE, createEncodingDecodingTab()), + ) + addTab( + UiToolsBundle.message("jwt-encoder-decoder.tab.validate"), + createTabContent(JwtTab.VALIDATE, createValidationTab()), + ) + + onSelectionChanged { selectedComponent -> + selectedComponent.getUserData(jwtTabKey)?.let { handleTabSelectionChanged(it) } + } + } + } + ) + .align(Align.FILL) + .resizableColumn() + } + .resizableRow() + } + + override fun afterBuildUi() { + convertFromUi(ChangeOrigin.ENCODED) + validateFromUi() + } + + override fun reset() { + convert(ChangeOrigin.ENCODED) + validateJwt() + } + + // -- Private Methods ----------------------------------------------------- // + + private fun createSharedEncodedComponent(): JComponent = panel { + row { label("").bindText(encodedEditorLabel).resizableColumn() }.bottomGap(BottomGap.NONE) + row { + cell(encodedEditor.component) + .validationOnApply(encodedEditor.bindValidator(jwt.encodedErrorHolder.asValidation())) + .validationRequestor(DUMMY_DIALOG_VALIDATION_REQUESTOR) + .align(Align.FILL) + .resizableColumn() + } + .resizableRow() + } + + private fun createEncodingDecodingTab(): JComponent = panel { + val signatureErrors = jwt.signatureErrorHolder.asComponentPredicate() + row { + text("") + .bindText(jwt.signatureErrorHolder.asPropertyForTextCell()) + .visibleIf(signatureErrors) + .resizableColumn() + } + .topGap(TopGap.NONE) + row { + cell(createEncodingDecodingComponent()).align(Align.FILL).resizableColumn() + } + .resizableRow() + .topGap(TopGap.NONE) + } + + private fun createValidationTab(): JComponent = panel { + row { text("").bindText(validation.tokenMetadata).resizableColumn() }.topGap(TopGap.NONE) + row { + text("") + .bindText(validation.validResultMessage) + .visibleIf( + PropertyComponentPredicate(validation.resultState, ValidationResultState.VALID) + ) + .resizableColumn() + text("") + .bindText(validation.invalidResultMessage) + .visibleIf( + PropertyComponentPredicate(validation.resultState, ValidationResultState.INVALID) + ) + .resizableColumn() + } + .topGap(TopGap.NONE) + row { + cell(createValidationComponent()).align(Align.FILL).resizableColumn() + } + .resizableRow() + .topGap(TopGap.NONE) + } + + @Suppress("UnstableApiUsage") + private fun createEncodingDecodingComponent(): JComponent = panel { + row { + val liveConversionCheckBox = + checkBox(UiToolsBundle.message("converter.live-conversion")) + .bindSelected(liveConversion) + .gap(RightGap.SMALL) + + button(UiToolsBundle.message("jwt-encoder-decoder.button.decode")) { + convert(ChangeOrigin.ENCODED) + } + .enabledIf(liveConversionCheckBox.selected.not()) + .gap(RightGap.SMALL) + button(UiToolsBundle.message("jwt-encoder-decoder.button.encode")) { + convert(ChangeOrigin.SIGNATURE_CONFIGURATION) + } + .enabledIf(liveConversionCheckBox.selected.not()) + } + + if (context.prioritizeVerticalLayout) { + row { + cell( + Splitter(true, 0.3f).apply { + firstComponent = createHeaderEditorComponent() + secondComponent = createPayloadEditorComponent() + } + ) + .align(Align.FILL) + .resizableColumn() + } + .resizableRow() + .bottomGap(BottomGap.NONE) + } else { + row { + cell( + Splitter(false, 0.5f).apply { + firstComponent = createHeaderEditorComponent() + secondComponent = createPayloadEditorComponent() + } + ) + .align(Align.FILL) + .resizableColumn() + } + .resizableRow() + .bottomGap(BottomGap.NONE) + } + + collapsibleGroup(UiToolsBundle.message("jwt-encoder-decoder.signature-configuration.title")) { + lateinit var signatureAlgorithmComboBox: ComboBox + row { + signatureAlgorithmComboBox = + comboBox(SignatureAlgorithm.entries) + .label( + UiToolsBundle.message("jwt-encoder-decoder.signature-configuration.algorithm") + ) + .bindItem(jwt.signature.algorithm) + .whenItemSelectedFromUi { convertFromUi(ChangeOrigin.SIGNATURE_CONFIGURATION) } + .component + } + .layout(RowLayout.PARENT_GRID) + .topGap(TopGap.NONE) + + row { + label(UiToolsBundle.message("jwt-encoder-decoder.signature-configuration.secret-key")) + expandableTextField() + .align(AlignX.FILL) + .bindText(jwt.signature.secret) + .whenTextChangedFromUi { convertFromUi(ChangeOrigin.SIGNATURE_CONFIGURATION) } + .gap(RightGap.SMALL) + .resizableColumn() + .registerDynamicToolTip { generalSettings.createSensitiveInputsHandlingToolTipText() } + + val encodingActions = + mutableListOf().apply { + SecretKeyEncodingMode.entries.forEach { secretKeyEncodingModeValue -> + add( + SimpleToggleAction( + text = secretKeyEncodingModeValue.title, + icon = AllIcons.Actions.ToggleSoftWrap, + isSelected = { + jwt.signature.secretEncodingMode.get() == secretKeyEncodingModeValue + }, + setSelected = { + jwt.signature.secretEncodingMode.set(secretKeyEncodingModeValue) + }, + ) + ) + } + } + actionButton( + UiUtils.actionsPopup( + title = + UiToolsBundle.message("jwt-encoder-decoder.signature-configuration.encoding"), + icon = AllIcons.General.Settings, + actions = encodingActions, + ) + ) + } + .visibleIf( + ComboBoxPredicate(signatureAlgorithmComboBox) { + it?.kind == SignatureAlgorithmKind.HMAC + } + ) + .layout(RowLayout.PARENT_GRID) + + row { + textArea() + .rows(5) + .align(Align.FILL) + .label( + label = + UiToolsBundle.message("jwt-encoder-decoder.signature-configuration.private-key"), + position = LabelPosition.TOP, + ) + .bindText(jwt.signature.privateKey) + .setValidationResultBorder() + .whenTextChangedFromUi { convertFromUi(ChangeOrigin.SIGNATURE_CONFIGURATION) } + .validationInfo(jwt.signature.privateKeyErrorHolder.asValidation()) + .registerDynamicToolTip { generalSettings.createSensitiveInputsHandlingToolTipText() } + } + .visibleIf(ComboBoxPredicate(signatureAlgorithmComboBox) { it?.kind?.keyFactory != null }) + + row { + checkBox(UiToolsBundle.message("jwt-encoder-decoder.strict-key-validation")) + .bindSelected(jwt.signature.strictSigningKeyValidation) + .whenStateChangedFromUi { convertFromUi(ChangeOrigin.SIGNATURE_CONFIGURATION) } + .gap(RightGap.SMALL) + contextHelp(UiToolsBundle.message("jwt-encoder-decoder.strict-key-validation.help")) + } + .visibleIf( + ComboBoxPredicate(signatureAlgorithmComboBox) { + it?.kind != SignatureAlgorithmKind.NONE + } + ) + } + .apply { expanded = false } + .topGap(TopGap.NONE) + } + + @Suppress("UnstableApiUsage") + private fun createValidationComponent(): JComponent = panel { + lateinit var validationSourceComboBox: ComboBox + row { + validationSourceComboBox = + comboBox(ValidationKeySource.entries) + .label(UiToolsBundle.message("jwt-encoder-decoder.validation.key-source")) + .bindItem(validation.keySource) + .whenItemSelectedFromUi { validateFromUi() } + .component + } + .layout(RowLayout.PARENT_GRID) + .topGap(TopGap.NONE) + + row { + label(UiToolsBundle.message("jwt-encoder-decoder.signature-configuration.secret-key")) + expandableTextField() + .align(AlignX.FILL) + .bindText(validation.secret) + .whenTextChangedFromUi { validateFromUi() } + .gap(RightGap.SMALL) + .resizableColumn() + .registerDynamicToolTip { generalSettings.createSensitiveInputsHandlingToolTipText() } + + val encodingActions = + mutableListOf().apply { + SecretKeyEncodingMode.entries.forEach { secretKeyEncodingModeValue -> + add( + SimpleToggleAction( + text = secretKeyEncodingModeValue.title, + icon = AllIcons.Actions.ToggleSoftWrap, + isSelected = { + validation.secretEncodingMode.get() == secretKeyEncodingModeValue + }, + setSelected = { validation.secretEncodingMode.set(secretKeyEncodingModeValue) }, + ) + ) + } + } + actionButton( + UiUtils.actionsPopup( + title = UiToolsBundle.message("jwt-encoder-decoder.signature-configuration.encoding"), + icon = AllIcons.General.Settings, + actions = encodingActions, + ) + ) + } + .visibleIf(ComboBoxPredicate(validationSourceComboBox) { it == ValidationKeySource.SECRET }) + .layout(RowLayout.PARENT_GRID) + + row { + textArea() + .rows(5) + .align(Align.FILL) + .label( + label = UiToolsBundle.message("jwt-encoder-decoder.validation.public-key-or-jwk"), + position = LabelPosition.TOP, + ) + .bindText(validation.publicKey) + .setValidationResultBorder() + .whenTextChangedFromUi { validateFromUi() } + .registerDynamicToolTip { generalSettings.createSensitiveInputsHandlingToolTipText() } + } + .visibleIf( + ComboBoxPredicate(validationSourceComboBox) { it == ValidationKeySource.PUBLIC_KEY } + ) + + row { + val jwksFetchEnabled = PropertyComponentPredicate(validation.fetchingJwks, false) + + textField() + .align(Align.FILL) + .label(UiToolsBundle.message("jwt-encoder-decoder.validation.jwks-url")) + .bindText(validation.jwksUrl) + .whenTextChangedFromUi { validateFromUi() } + .gap(RightGap.SMALL) + .resizableColumn() + .enabledIf(jwksFetchEnabled) + button(UiToolsBundle.message("jwt-encoder-decoder.validation.fetch")) { fetchJwks() } + .enabledIf(jwksFetchEnabled) + } + .visibleIf(ComboBoxPredicate(validationSourceComboBox) { it == ValidationKeySource.JWKS }) + .layout(RowLayout.PARENT_GRID) + + row { + textArea() + .rows(8) + .align(Align.FILL) + .label( + label = UiToolsBundle.message("jwt-encoder-decoder.validation.jwks-json"), + position = LabelPosition.TOP, + ) + .bindText(validation.jwksJson) + .setValidationResultBorder() + .whenTextChangedFromUi { validateFromUi() } + } + .visibleIf(ComboBoxPredicate(validationSourceComboBox) { it == ValidationKeySource.JWKS }) + + row { + checkBox(UiToolsBundle.message("jwt-encoder-decoder.strict-key-validation")) + .bindSelected(validation.strictKeyValidation) + .whenStateChangedFromUi { validateFromUi() } + .gap(RightGap.SMALL) + contextHelp(UiToolsBundle.message("jwt-encoder-decoder.strict-key-validation.help")) + } + .topGap(TopGap.NONE) + } + + private fun createPayloadEditorComponent(): JComponent = panel { + row { + cell(payloadEditor.component) + .validationOnApply(payloadEditor.bindValidator(jwt.payloadErrorHolder.asValidation())) + .validationRequestor(DUMMY_DIALOG_VALIDATION_REQUESTOR) + .align(Align.FILL) + .resizableColumn() + } + .resizableRow() + } + + private fun createHeaderEditorComponent(): JComponent = panel { + row { + cell(headerEditor.component) + .validationOnApply(headerEditor.bindValidator(jwt.headerErrorHolder.asValidation())) + .validationRequestor(DUMMY_DIALOG_VALIDATION_REQUESTOR) + .align(Align.FILL) + .resizableColumn() + } + .resizableRow() + } + + private fun convertFromUi(changeOrigin: ChangeOrigin) { + if (!liveConversion.get()) { + return + } + + convert(changeOrigin) + } + + private fun validateFromUi() { + if (configuration.isResetting) { + return + } + + if (!isDisposed && !validationAlarm.isDisposed) { + validationAlarm.cancelAllRequests() + validationAlarm.addRequest({ validateJwt() }, 100) + } + } + + private fun convert(changeOrigin: ChangeOrigin) { + if (configuration.isResetting) { + return + } + + if (!isDisposed && !conversionAlarm.isDisposed) { + conversionAlarm.cancelAllRequests() + conversionAlarm.addRequest({ doConvert(changeOrigin) }, 100) + } + } + + private fun doConvert(changeOrigin: ChangeOrigin) { + when (changeOrigin) { + ChangeOrigin.ENCODED -> jwt.decodeJwt() + ChangeOrigin.HEADER_OR_PAYLOAD -> jwt.encodeJwt() + ChangeOrigin.SIGNATURE_CONFIGURATION -> { + jwt.setAlgorithmInHeader() + jwt.encodeJwt() + } + } + + validation.validate(encodedText.get()) + refreshHighlights() + validate() + } + + private fun validateJwt() { + jwt.decodeJwt() + validation.validate(encodedText.get()) + refreshHighlights() + validate() + } + + private fun refreshHighlights() { + highlighter.refresh( + encodedEditor = encodedEditor, + headerEditor = headerEditor, + payloadEditor = payloadEditor, + isToolDisposed = isDisposed, + ) + } + + private fun createEncodedEditor(): AdvancedEditor = + createEditor( + id = "encoded", + changeOrigin = ChangeOrigin.ENCODED, + title = null, + language = PlainTextLanguage.INSTANCE, + textProperty = encodedText, + ) { + if (selectedTab == JwtTab.VALIDATE && !liveConversion.get()) { + validateFromUi() + } + highlighter.scheduleDotSeparatorHighlight(encodedEditor, isDisposed) + } + + private fun createHeaderEditor(): AdvancedEditor = + createEditor( + id = "header", + changeOrigin = ChangeOrigin.HEADER_OR_PAYLOAD, + title = UiToolsBundle.message("jwt-encoder-decoder.editor.header"), + language = JsonLanguage.INSTANCE, + textProperty = headerText, + ) { + highlighter.scheduleHeaderClaimsHighlight(headerEditor, isDisposed) + } + + private fun createPayloadEditor(): AdvancedEditor = + createEditor( + id = "payload", + changeOrigin = ChangeOrigin.HEADER_OR_PAYLOAD, + title = UiToolsBundle.message("jwt-encoder-decoder.editor.payload"), + language = JsonLanguage.INSTANCE, + textProperty = payloadText, + ) { + highlighter.schedulePayloadClaimsHighlight(payloadEditor, isDisposed) + } + + private fun createEditor( + id: String, + changeOrigin: ChangeOrigin, + title: String?, + language: Language, + textProperty: ValueProperty, + onTextChangeFromUi: (() -> Unit)? = null, + ) = + AdvancedEditor( + id = id, + context = context, + configuration = configuration, + project = project, + title = title, + editorMode = EditorMode.INPUT_OUTPUT, + parentDisposable = parentDisposable, + textProperty = textProperty, + initialLanguage = language, + ) + .apply { + onFocusGained { lastActiveInput = this } + this.onTextChangeFromUi { _ -> + lastActiveInput = this + convertFromUi(changeOrigin) + onTextChangeFromUi?.invoke() + } + } + + private fun handleLiveConversionSwitch() { + if (liveConversion.get()) { + when (lastActiveInput) { + encodedEditor -> convert(ChangeOrigin.ENCODED) + headerEditor, + payloadEditor -> convert(ChangeOrigin.SIGNATURE_CONFIGURATION) + + null -> {} + } + } + } + + private fun createTabContent(jwtTab: JwtTab, component: JComponent): JComponent = + component.apply { putUserData(jwtTabKey, jwtTab) }.wrapTabbedPaneContent() + + private fun handleTabSelectionChanged(jwtTab: JwtTab) { + selectedTab = jwtTab + encodedEditorLabel.set(sharedEncodedEditorLabel(jwtTab)) + if (jwtTab == JwtTab.VALIDATE) { + validateFromUi() + } + } + + private fun sharedEncodedEditorLabel(jwtTab: JwtTab): String = + when (jwtTab) { + JwtTab.DECODE_ENCODE -> + UiToolsBundle.message("jwt-encoder-decoder.editor.jwt-input-output") + JwtTab.VALIDATE -> UiToolsBundle.message("jwt-encoder-decoder.editor.jwt-input") + } + + private fun fetchJwks() { + validation.fetchJwks(project) { validateJwt() } + } + + // -- Inner Type ---------------------------------------------------------- // + + class Factory : DeveloperUiToolFactory { + + override fun getDeveloperUiToolPresentation() = + DeveloperUiToolPresentation( + menuTitle = UiToolsBundle.message("jwt-encoder-decoder.menu-title"), + contentTitle = UiToolsBundle.message("jwt-encoder-decoder.content-title"), + ) + + override fun getDeveloperUiToolCreator( + project: Project?, + parentDisposable: Disposable, + context: DeveloperUiToolContext, + ): ((DeveloperToolConfiguration) -> JwtEncoderDecoder) = { configuration -> + JwtEncoderDecoder(context, configuration, parentDisposable, project) + } + } + + // -- Companion Object ---------------------------------------------------- // + + companion object { + private val jwtTabKey = com.intellij.openapi.util.Key.create("jwtTab") + + internal val urlEncoder: Base64.Encoder = Base64.getUrlEncoder().withoutPadding() + + internal val unixTimestampSecondsJsonValueRegex = + Regex(":\\s*(?\\b\\d{1,10}\\b)") + internal val claimRegex = Regex("\\s?\"(?[a-zA-Z]+)\"\\s?:") + internal val rawKeyRegex = Regex("\\r?\\n|\\r|\\s?-+(BEGIN|END).*KEY-+\\s?") + + internal val defaultSignatureAlgorithm = SignatureAlgorithm.HMAC256 + internal const val SIGNING_KEY_VALIDATION_DEFAULT = false + + internal const val EXAMPLE_ENCODED = + "ewogICJ0eXAiOiJKV1QiLAogICJhbGciOiJIUzI1NiIKfQ.ewogICJqdGkiOiI5NjQ5MmQ1OS0wYWQ1LTRjMDAtODkyZC01OTBhZDVhYzAwZjMiLAogICJzdWIiOiIwMTIzNDU2Nzg5IiwKICAibmFtZSI6IkpvaG4gRG9lIiwKICAiaWF0IjoxNjgxMDQwNTE1Cn0.IqeNl3lHSUfPfEYmttvlQp1sH9LpAoPJlUiSv4XPDSE" + internal const val EXAMPLE_SECRET = "s3cre!" + internal val exampleHeader = + """ + { + "typ":"JWT", + "alg":"HS256" + } + """ + .trimIndent() + internal val examplePayload = + """ + { + "jti":"96492d59-0ad5-4c00-892d-590ad5ac00f3", + "sub":"0123456789", + "name":"John Doe", + "iat":1681040515 + } + """ + .trimIndent() + + internal val exampleRsaPrivateKey = + """ +-----BEGIN RSA PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDdadLFj3DqaYtpZ1ik6ejpIIAU +2KhFqygTvR6SSS9RmcFQu/vojHWzQUhm8aqrGYVkDXCHvEcyBPcZUlWBczcDwQ5YF8VktRpMxfAI +K/OZRmfrhK9jAZsxOPCCXMOY+JoCbEqEOpsClbHKbgNBgw4AfsISzuWODa47KucIQad202lUZMQ5 +iBQ9CRcSfSis6HyvCMTY5li/9a+O78FfqIGUE4FHeJpsiay2z2AMEzwBPoURkTaSjjOT25e+GY7k +ntilnVne1ORdOPMnOcd28COex55Z+C4QlOr2UIDaAinTAG/0ozwWxd8OaVJJy3mj3dd3AeD2vBMm +ycnhrM+sccqHAgMBAAECggEARelztZDg2QuhixMoUM5RDkeGWc69d14fZfgpzowQRmZTvZ/V32x2 +f7bl2yeEucjxrxF1Tk67dkZOFa9DM4BDR0qusk8zM2Th3IsFizcBkIzEJIA9dvgbXjP58VfEJSme +S5SRBOaSaoME5APPwGBWy/46XoD4x912/dTCpX9Blwl81i7EO8o3NnYhsCWeVoUJTWBzN95OchZF +ozV4pFgv0tZqTNa7VhJtWHHiKkCpdK7gA9SeVEqEeL1TADAa2ngy3BIRfTgdAct6/4N+ZlVsaIXB +1Gnw2RaOoUbHy1PCA6ygtH5lz65p0JdWGcO5l+JNeYmOIeOdJ3QWbVI+CikPGQKBgQDv/JrTewDt +BK5KBpotFsrJeDFKOkC6A8aNeGliAEgJYvCk7zb8RtKCx7ViaYGYWJYj30oYejYEE+vFT7sgzXfE +JPAEiMw4uKeIrbX8QEIP+R25S8iRr657DkTxOvyhO2oQcC7UkZagvrVyQ17VgjtjxGWbc5bRBk5v +u1ZV9VMZuQKBgQDsL/PsLCX3YRBB5+0rpWoTKKrmFtGh31oue+d37Nd7oxBzb2uyF4Q29+zoy1on +EnHNamjjdR95NZoOjEsIIKDTV1C/bsS7be53m0mwKQfecKIXJ+7VN4UsYZXjCajHCr3NFHiIU8ct +pcKGtg7ga5cERIBtrPAi9Qzi7/o1MxUmPwKBgFuSaMWPZuAJ7DNE56mSy9gqa6xmI/KWpDmxG40Q +jGxAe5CD0thacdMDPzwJBDFMhCW1+wDyCRBvRYSpkr7GiA+pBIjGZh6ynwKxPgK9xjdwGB5vQ14L +yikcXcQqfOFM2YDiPYxQ7Ufy3St3d4VCx0SfWSIC7iZeIKnTsvLjxEzJAoGBAKcLFzou0z9N3+Cs +9pnK6OXZ+ly3QNZ6kF6V9VRlJtXjs0vhPsr7ROBXoq/WutEtg11j6AEPIg5o8adeY+bApN40QADU +h8GD84eWRZyYuF8DTDCSZqFYHhEQh6DGgR8dIrX7x2+ryRAozxbVhloE3g7/n9Fx4Xjn1ZBfZ5fe +pBOjAoGAcw2M22BK3NWOHhJ8EC4p6aUIR96lNcCWE/ij+MWCcRdotLDSDuT1q13C+UTxDZ5PsmDs +N/bhCDRZYZoLYo0/h6v4zKBDaX05nVUTCYux0Fo2HGrj5S0bjmgyRcr8+enA3CTzCHZPWZ7ZeADb +0Mbtt/Q4JyOCgwORgXJVQBHxxIQ= +-----END RSA PRIVATE KEY----- + """ + .trimIndent() + + internal val exampleEcPrivateKey = + """ +-----BEGIN EC PRIVATE KEY----- +MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCDQ+B6qEzr/M2sql4X+09X9YlYt8BKA +HX8Q7/6s4KC3qQ== +-----END RSA PRIVATE KEY----- + """ + .trimIndent() + } + + private enum class JwtTab { + DECODE_ENCODE, + VALIDATE, + } +} diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncodingModel.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncodingModel.kt new file mode 100644 index 0000000..9e8c314 --- /dev/null +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncodingModel.kt @@ -0,0 +1,496 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.jwtencoderdecoder + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.intellij.util.ExceptionUtil +import dev.turingcomplete.intellijdevelopertoolsplugin.common.ValueProperty +import dev.turingcomplete.intellijdevelopertoolsplugin.common.decodeBase64String +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.CONFIGURATION +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.SENSITIVE +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolsApplicationSettings.Companion.generalSettings +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.ObjectMapperService +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.ErrorHolder +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.message.UiToolsBundle +import java.security.Key +import java.security.KeyFactory +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import org.apache.commons.codec.binary.Base32 +import org.jose4j.jws.AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256 +import org.jose4j.jws.AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384 +import org.jose4j.jws.AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512 +import org.jose4j.jws.AlgorithmIdentifiers.HMAC_SHA256 +import org.jose4j.jws.AlgorithmIdentifiers.HMAC_SHA384 +import org.jose4j.jws.AlgorithmIdentifiers.HMAC_SHA512 +import org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA256 +import org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA384 +import org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA512 +import org.jose4j.jws.JsonWebSignature +import org.jose4j.keys.HmacKey + +internal enum class ChangeOrigin { + + ENCODED, + HEADER_OR_PAYLOAD, + SIGNATURE_CONFIGURATION, +} + +internal enum class SignatureAlgorithmKind(val keyFactory: KeyFactory?) { + + NONE(null), + HMAC(null), + RSA(KeyFactory.getInstance("RSA")), + ECDSA(KeyFactory.getInstance("EC")), +} + +internal enum class SignatureAlgorithm( + val jwtHeaderValue: String, + val kind: SignatureAlgorithmKind, + @Suppress("unused") // May be used for JWK validation + val algorithmIdentifiers: String, +) { + + NONE("none", SignatureAlgorithmKind.NONE, "none"), + HMAC256("HS256", SignatureAlgorithmKind.HMAC, HMAC_SHA256), + HMAC384("HS384", SignatureAlgorithmKind.HMAC, HMAC_SHA384), + HMAC512("HS512", SignatureAlgorithmKind.HMAC, HMAC_SHA512), + RSA256("RS256", SignatureAlgorithmKind.RSA, RSA_USING_SHA256), + RSA384("RS384", SignatureAlgorithmKind.RSA, RSA_USING_SHA384), + RSA512("RS512", SignatureAlgorithmKind.RSA, RSA_USING_SHA512), + ECDSA256("ES256", SignatureAlgorithmKind.ECDSA, ECDSA_USING_P256_CURVE_AND_SHA256), + ECDSA384("ES384", SignatureAlgorithmKind.ECDSA, ECDSA_USING_P384_CURVE_AND_SHA384), + ECDSA512("ES512", SignatureAlgorithmKind.ECDSA, ECDSA_USING_P521_CURVE_AND_SHA512); + + override fun toString(): String = + if (this == NONE) { + UiToolsBundle.message("jwt-encoder-decoder.signature-algorithm.none") + } else { + UiToolsBundle.message("jwt-encoder-decoder.signature-algorithm.named", name, jwtHeaderValue) + } + + companion object { + + fun findByJwtHeaderValue(jwtHeaderValue: String): SignatureAlgorithm? = + entries.firstOrNull { it.jwtHeaderValue == jwtHeaderValue } + } +} + +internal enum class SecretKeyEncodingMode(val title: String) { + + RAW(UiToolsBundle.message("jwt-encoder-decoder.secret-key-encoding-mode.raw")), + BASE32(UiToolsBundle.message("jwt-encoder-decoder.secret-key-encoding-mode.base32")), + BASE64(UiToolsBundle.message("jwt-encoder-decoder.secret-key-encoding-mode.base64")), +} + +internal enum class ValidationKeySource(private val title: String) { + + SECRET(UiToolsBundle.message("jwt-encoder-decoder.validation-key-source.secret")), + PUBLIC_KEY(UiToolsBundle.message("jwt-encoder-decoder.validation-key-source.public-key")), + JWKS(UiToolsBundle.message("jwt-encoder-decoder.validation-key-source.jwks")); + + override fun toString(): String = title +} + +internal enum class ValidationResultState { + + NONE, + VALID, + INVALID, +} + +internal enum class StandardClaim( + val fieldName: String, + val title: String, + val description: String, +) { + + TYP( + fieldName = "typ", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.typ.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.typ.description"), + ), + ISSUER( + fieldName = "iss", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.iss.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.iss.description"), + ), + SUBJECT( + fieldName = "sub", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.sub.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.sub.description"), + ), + AUDIENCE( + fieldName = "aud", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.aud.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.aud.description"), + ), + EXPIRATION_TIME( + fieldName = "exp", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.exp.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.exp.description"), + ), + NOT_BEFORE_TIME( + fieldName = "nbf", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.nbf.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.nbf.description"), + ), + ISSUED_AT_TIME( + fieldName = "iat", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.iat.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.iat.description"), + ), + JWT_ID( + fieldName = "jti", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.jti.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.jti.description"), + ), + ALG( + fieldName = "alg", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.alg.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.alg.description"), + ), + AZP( + fieldName = "azp", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.azp.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.azp.description"), + ), + SID( + fieldName = "sid", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.sid.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.sid.description"), + ), + NONCE( + fieldName = "nonce", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.nonce.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.nonce.description"), + ), + AT_HASH( + fieldName = "at_hash", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.at-hash.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.at-hash.description"), + ), + C_HASH( + fieldName = "c_hash", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.c-hash.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.c-hash.description"), + ), + ACT( + fieldName = "act", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.act.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.act.description"), + ), + AUTH_TIME( + fieldName = "auth_time", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.auth-time.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.auth-time.description"), + ), + SCOPE( + fieldName = "scope", + title = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.scope.title"), + description = UiToolsBundle.message("jwt-encoder-decoder.standard-claim.scope.description"), + ); + + override fun toString(): String = + UiToolsBundle.message( + "jwt-encoder-decoder.standard-claim.tooltip", + title, + fieldName, + description, + ) + + companion object { + + fun findByFieldName(fieldName: String): StandardClaim? = + entries.firstOrNull { it.fieldName == fieldName } + } +} + +internal class Jwt( + configuration: DeveloperToolConfiguration, + val encoded: ValueProperty, + val header: ValueProperty, + val payload: ValueProperty, +) { + + val encodedErrorHolder = ErrorHolder() + val headerErrorHolder = ErrorHolder() + val payloadErrorHolder = ErrorHolder() + val signatureErrorHolder = + ErrorHolder(addErrorIconToMessage = true, surroundMessageWithHtml = false) + + val signature = Signature(configuration, signatureErrorHolder) + + fun decodeJwt() { + clearErrorHolders() + + val jwtParts = encoded.get().split('.', limit = 3) + val numOfJwtParts = jwtParts.size + + if (numOfJwtParts >= 1) { + val handleError: (Exception) -> Unit = { error -> + header.set(jwtParts[0]) + headerErrorHolder.add(error) + } + parseAsJson(text = jwtParts[0], textIsBase64 = true, handleError) { + parseHeader(it) + header.set(ObjectMapperService.instance.prettyPrintJson(it)) + } + } else { + header.set("") + } + + if (numOfJwtParts >= 2) { + val handleError: (Exception) -> Unit = { error -> + payload.set(jwtParts[1]) + payloadErrorHolder.add(error) + } + parseAsJson(text = jwtParts[1], textIsBase64 = true, handleError) { + payload.set(ObjectMapperService.instance.prettyPrintJson(it)) + } + } else { + payload.set("") + } + } + + fun encodeJwt() { + clearErrorHolders() + + val jsonMapper = ObjectMapperService.instance.jsonMapper() + + val headerJson = + try { + val headerJson = jsonMapper.readTree(header.get()) + parseHeader(headerJson) + headerJson + } catch (e: Exception) { + headerErrorHolder.add(e) + null + } + + val payloadJson = + try { + jsonMapper.readTree(payload.get()) + } catch (e: Exception) { + payloadErrorHolder.add(e) + null + } + + if (headerErrorHolder.isSet() || payloadErrorHolder.isSet()) { + encoded.set("") + signatureErrorHolder.add( + UiToolsBundle.message( + "jwt-encoder-decoder.encode.signature-unavailable.header-payload-errors" + ) + ) + } else { + val encodedHeader = + JwtEncoderDecoder.urlEncoder + .encode(jsonMapper.writeValueAsString(headerJson!!).encodeToByteArray()) + .decodeToString() + val encodedPayload = + JwtEncoderDecoder.urlEncoder + .encode(jsonMapper.writeValueAsString(payloadJson!!).encodeToByteArray()) + .decodeToString() + val encodedSignature = signature.compute(encodedHeader, encodedPayload) + if (encodedSignature == null) { + encoded.set("") + signatureErrorHolder.addIfNoErrors( + UiToolsBundle.message( + "jwt-encoder-decoder.encode.signature-unavailable.configuration-errors" + ) + ) + } else { + encoded.set("${encodedHeader}.${encodedPayload}.$encodedSignature") + } + } + } + + fun setAlgorithmInHeader() { + parseAsJson(text = header.get(), textIsBase64 = false, { headerErrorHolder.add(it) }) { + headerNode -> + if (headerNode is ObjectNode) { + headerNode.put("alg", signature.algorithm.get().jwtHeaderValue) + header.set(ObjectMapperService.instance.prettyPrintJson(headerNode)) + } + } + } + + private fun clearErrorHolders() { + encodedErrorHolder.clear() + headerErrorHolder.clear() + payloadErrorHolder.clear() + signatureErrorHolder.clear() + } + + private fun parseHeader(headerNode: JsonNode) { + if (headerNode.has("alg")) { + val algFieldValue = headerNode.get("alg").asText() + val algorithm = SignatureAlgorithm.findByJwtHeaderValue(algFieldValue) + if (algorithm != null) { + signature.algorithm.set(algorithm) + } else { + headerErrorHolder.add( + UiToolsBundle.message("jwt-encoder-decoder.header.unsupported-algorithm", algFieldValue) + ) + } + } else { + headerErrorHolder.add(UiToolsBundle.message("jwt-encoder-decoder.header.missing-algorithm")) + } + } + + private fun parseAsJson( + text: String, + textIsBase64: Boolean, + handleError: (Exception) -> Unit, + handleResult: (JsonNode) -> Unit, + ) { + try { + val actualText = if (textIsBase64) text.decodeBase64String() else text + val jsonNode = ObjectMapperService.instance.jsonMapper().readTree(actualText) + handleResult(jsonNode) + } catch (e: Exception) { + handleError(e) + } + } +} + +internal class Signature( + configuration: DeveloperToolConfiguration, + private val signatureErrorHolder: ErrorHolder, +) { + + val algorithm = configuration.register("algorithm", JwtEncoderDecoder.defaultSignatureAlgorithm) + val strictSigningKeyValidation = + configuration.register( + "signingKeyValidation", + JwtEncoderDecoder.SIGNING_KEY_VALIDATION_DEFAULT, + CONFIGURATION, + ) + val secret = configuration.register("secret", "", SENSITIVE, JwtEncoderDecoder.EXAMPLE_SECRET) + val privateKey = + configuration.registerWithExampleProvider("privateKey", "", SENSITIVE) { + if (algorithm.get().kind == SignatureAlgorithmKind.RSA) { + JwtEncoderDecoder.exampleRsaPrivateKey + } else { + JwtEncoderDecoder.exampleEcPrivateKey + } + } + val secretEncodingMode = + configuration.register("secretKeyEncodingMode", SecretKeyEncodingMode.RAW, CONFIGURATION) + + val privateKeyErrorHolder = ErrorHolder() + + init { + handleAlgorithmChange() + algorithm.afterChangeConsumeEvent(null) { event -> + if (event.valueChanged()) { + handleAlgorithmChange() + } + } + } + + fun compute(encodedHeader: String, encodedPayload: String): String? { + privateKeyErrorHolder.clear() + + return try { + if (algorithm.get() == SignatureAlgorithm.NONE) { + return "" + } + val signingKey = createSigningKey() ?: return null + ExtendedJsonWebSignature() + .apply { + setEncodedHeader(encodedHeader) + setEncodedPayload(encodedPayload) + setKey(signingKey) + isDoKeyValidation = strictSigningKeyValidation.get() + sign() + } + .encodedSignature + } catch (e: Exception) { + signatureErrorHolder.add( + UiToolsBundle.message("jwt-encoder-decoder.signature.compute-failed"), + ExceptionUtil.getRootCause(e), + ) + null + } + } + + private fun createSigningKey(): Key? { + val signatureAlgorithm = algorithm.get() + return when (signatureAlgorithm.kind) { + SignatureAlgorithmKind.NONE -> null + SignatureAlgorithmKind.HMAC -> + HmacKey( + when (secretEncodingMode.get()) { + SecretKeyEncodingMode.RAW -> secret.get().encodeToByteArray() + SecretKeyEncodingMode.BASE32 -> Base32().decode(secret.get()) + SecretKeyEncodingMode.BASE64 -> Base64.getDecoder().decode(secret.get()) + } + ) + + SignatureAlgorithmKind.RSA, + SignatureAlgorithmKind.ECDSA -> + readPrivateKey(signatureAlgorithm.kind.keyFactory!!) ?: return null + } + } + + private fun readPrivateKey(keyFactory: KeyFactory) = + try { + val privateKeyValue = privateKey.get() + if (privateKeyValue.isBlank()) { + privateKeyErrorHolder.add( + UiToolsBundle.message("jwt-encoder-decoder.signature.private-key-required") + ) + null + } else { + keyFactory.generatePrivate(PKCS8EncodedKeySpec(toRawKey(privateKey.get()))) + } + } catch (e: Exception) { + privateKeyErrorHolder.add(e) + null + } + + private fun toRawKey(keyInput: String): ByteArray = + Base64.getDecoder().decode(keyInput.replace(JwtEncoderDecoder.rawKeyRegex, "")) + + private fun handleAlgorithmChange() { + if (generalSettings.loadExamples.get()) { + loadExampleSecrets() + } + } + + private fun loadExampleSecrets() { + val privateKeyValue = privateKey.get() + when (algorithm.get().kind) { + SignatureAlgorithmKind.NONE -> {} + SignatureAlgorithmKind.HMAC -> { + if (secret.get().isBlank()) { + secret.set(JwtEncoderDecoder.EXAMPLE_SECRET) + } + } + + SignatureAlgorithmKind.RSA -> { + if (privateKeyValue.isBlank() || privateKeyValue == JwtEncoderDecoder.exampleEcPrivateKey) { + privateKey.set(JwtEncoderDecoder.exampleRsaPrivateKey) + } + } + + SignatureAlgorithmKind.ECDSA -> { + if ( + privateKeyValue.isBlank() || privateKeyValue == JwtEncoderDecoder.exampleRsaPrivateKey + ) { + privateKey.set(JwtEncoderDecoder.exampleEcPrivateKey) + } + } + } + } +} + +internal class ExtendedJsonWebSignature : JsonWebSignature() { + + @Suppress("RedundantVisibilityModifier") + public override fun setEncodedHeader(encodedHeader: String?) { + super.setEncodedHeader(encodedHeader) + } +} diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtValidation.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtValidation.kt new file mode 100644 index 0000000..789503d --- /dev/null +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtValidation.kt @@ -0,0 +1,439 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.jwtencoderdecoder + +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.util.ExceptionUtil +import dev.turingcomplete.intellijdevelopertoolsplugin.common.OkHttpClientUtils.applyIntelliJProxySettings +import dev.turingcomplete.intellijdevelopertoolsplugin.common.ValueProperty +import dev.turingcomplete.intellijdevelopertoolsplugin.common.decodeBase64String +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.CONFIGURATION +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.INPUT +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.SENSITIVE +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.ObjectMapperService +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.message.UiToolsBundle +import java.security.Key +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 +import okhttp3.OkHttpClient +import okhttp3.Request +import org.apache.commons.codec.binary.Base32 +import org.jose4j.jwk.JsonWebKey +import org.jose4j.jwk.JsonWebKeySet +import org.jose4j.jws.JsonWebSignature +import org.jose4j.keys.HmacKey + +internal class JwtValidation(configuration: DeveloperToolConfiguration) { + + private val keySourceName = + configuration.register("validationKeySource", ValidationKeySource.SECRET.name, CONFIGURATION) + val keySource = + ValueProperty( + runCatching { ValidationKeySource.valueOf(keySourceName.get()) } + .getOrDefault(ValidationKeySource.SECRET) + ) + val secret = + configuration.register("validationSecret", "", SENSITIVE, JwtEncoderDecoder.EXAMPLE_SECRET) + val publicKey = configuration.register("validationPublicKey", "", SENSITIVE) + val jwksUrl = configuration.register("validationJwksUrl", "", INPUT) + val jwksJson = configuration.register("validationJwksJson", "", INPUT) + val secretEncodingMode = + configuration.register( + "validationSecretKeyEncodingMode", + SecretKeyEncodingMode.RAW, + CONFIGURATION, + ) + val strictKeyValidation = + configuration.register( + "validationStrictKeyValidation", + JwtEncoderDecoder.SIGNING_KEY_VALIDATION_DEFAULT, + CONFIGURATION, + ) + + val tokenMetadata = + ValueProperty(UiToolsBundle.message("jwt-encoder-decoder.validation.token-metadata.default")) + val resultState = ValueProperty(ValidationResultState.NONE) + val validResultMessage = ValueProperty("") + val invalidResultMessage = ValueProperty("") + val fetchingJwks = ValueProperty(false) + + init { + keySource.afterChangeConsumeEvent(null) { event -> + if (event.valueChanged()) { + keySourceName.set(event.newValue.name) + } + } + } + + fun fetchJwks(project: Project?, onFetchSuccess: () -> Unit = {}) { + val url = jwksUrl.get().trim() + if (url.isBlank()) { + setInvalidResult(UiToolsBundle.message("jwt-encoder-decoder.validation.fetch-jwks.enter-url")) + return + } + if (fetchingJwks.get()) { + return + } + + fetchingJwks.set(true) + + object : + Task.Backgroundable( + project, + UiToolsBundle.message("jwt-encoder-decoder.validation.fetch-jwks.in-progress-title"), + true, + ) { + private lateinit var fetchedJwks: String + + override fun run(indicator: ProgressIndicator) { + indicator.text = + UiToolsBundle.message("jwt-encoder-decoder.validation.fetch-jwks.in-progress") + + val httpClient = OkHttpClient.Builder().applyIntelliJProxySettings(url).build() + val request = Request.Builder().url(url).build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val statusMessage = response.message.ifBlank { response.code.toString() } + error("HTTP ${response.code}: $statusMessage") + } + fetchedJwks = response.body.string() + } + } + + override fun onSuccess() { + jwksJson.set(fetchedJwks) + onFetchSuccess() + } + + override fun onThrowable(error: Throwable) { + setInvalidResult( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.fetch-jwks.failed", + rootCauseMessage(error), + ) + ) + } + + override fun onFinished() { + fetchingJwks.set(false) + } + } + .queue() + } + + fun validate(encodedJwt: String) { + if (encodedJwt.isBlank()) { + tokenMetadata.set( + UiToolsBundle.message("jwt-encoder-decoder.validation.token-metadata.default") + ) + setNeutralResult() + return + } + + try { + val jwtParts = encodedJwt.split('.', limit = 3) + if (jwtParts.size < 2) { + tokenMetadata.set( + UiToolsBundle.message("jwt-encoder-decoder.validation.token-metadata.unknown") + ) + setInvalidResult( + UiToolsBundle.message("jwt-encoder-decoder.validation.invalid-compact-jwt.header-payload") + ) + return + } + + val headerNode = + ObjectMapperService.instance.jsonMapper().readTree(jwtParts[0].decodeBase64String()) + val algorithm = + headerNode.get("alg")?.asText()?.let { SignatureAlgorithm.findByJwtHeaderValue(it) } + ?: run { + tokenMetadata.set( + UiToolsBundle.message("jwt-encoder-decoder.validation.token-metadata.unknown") + ) + setInvalidResult( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.missing-or-unsupported-alg-header" + ) + ) + return + } + val keyId = headerNode.get("kid")?.asText()?.takeIf { it.isNotBlank() } + tokenMetadata.set( + buildString { + append( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.token-metadata.algorithm", + algorithm.jwtHeaderValue, + ) + ) + if (keyId != null) { + append( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.token-metadata.kid-suffix", + keyId, + ) + ) + } + } + ) + + if (algorithm == SignatureAlgorithm.NONE) { + if (jwtParts.getOrElse(2) { "" }.isEmpty()) { + setValidResult( + UiToolsBundle.message("jwt-encoder-decoder.validation.valid-unsecured-jwt") + ) + } else { + setInvalidResult( + UiToolsBundle.message("jwt-encoder-decoder.validation.invalid-jwt.alg-none-signature") + ) + } + return + } + + if (jwtParts.size < 3) { + setInvalidResult( + UiToolsBundle.message("jwt-encoder-decoder.validation.invalid-compact-jwt.signature") + ) + return + } + + val outcome = + when (keySource.get()) { + ValidationKeySource.SECRET -> validateWithSecret(encodedJwt, algorithm) + ValidationKeySource.PUBLIC_KEY -> validateWithPublicKey(encodedJwt, algorithm) + ValidationKeySource.JWKS -> validateWithJwks(encodedJwt, algorithm, keyId) + } + if (outcome.valid) { + setValidResult(outcome.message) + } else { + setInvalidResult(outcome.message) + } + } catch (e: Exception) { + tokenMetadata.set( + UiToolsBundle.message("jwt-encoder-decoder.validation.token-metadata.unknown") + ) + setInvalidResult( + UiToolsBundle.message("jwt-encoder-decoder.validation.failed", rootCauseMessage(e)) + ) + } + } + + private fun validateWithSecret( + encodedJwt: String, + algorithm: SignatureAlgorithm, + ): ValidationOutcome { + if (algorithm.kind != SignatureAlgorithmKind.HMAC) { + return ValidationOutcome( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.key-source-mismatch.public-key-or-jwks", + algorithm.jwtHeaderValue, + ), + false, + ) + } + + val signingKey = + HmacKey( + when (secretEncodingMode.get()) { + SecretKeyEncodingMode.RAW -> secret.get().encodeToByteArray() + SecretKeyEncodingMode.BASE32 -> Base32().decode(secret.get()) + SecretKeyEncodingMode.BASE64 -> Base64.getDecoder().decode(secret.get()) + } + ) + return if (verifySignature(encodedJwt, signingKey)) { + ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.signature.valid"), + true, + ) + } else { + ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.signature.invalid"), + false, + ) + } + } + + private fun validateWithPublicKey( + encodedJwt: String, + algorithm: SignatureAlgorithm, + ): ValidationOutcome { + if (algorithm.kind == SignatureAlgorithmKind.HMAC) { + return ValidationOutcome( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.key-source-mismatch.shared-secret", + algorithm.jwtHeaderValue, + ), + false, + ) + } + + val keyInput = publicKey.get().trim() + if (keyInput.isBlank()) { + return ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.provide-public-key-or-jwk"), + false, + ) + } + + val verificationKey = + try { + readVerificationKey(keyInput, algorithm) + } catch (e: Exception) { + return ValidationOutcome( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.read-public-key-or-jwk.failed", + rootCauseMessage(e), + ), + false, + ) + } + + return if (verifySignature(encodedJwt, verificationKey)) { + ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.signature.valid"), + true, + ) + } else { + ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.signature.invalid"), + false, + ) + } + } + + private fun validateWithJwks( + encodedJwt: String, + algorithm: SignatureAlgorithm, + keyId: String?, + ): ValidationOutcome { + val jwksValue = jwksJson.get().trim() + if (jwksValue.isBlank()) { + return ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.provide-jwks-json"), + false, + ) + } + + val jwks = + try { + JsonWebKeySet(jwksValue) + } catch (e: Exception) { + return ValidationOutcome( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.parse-jwks.failed", + rootCauseMessage(e), + ), + false, + ) + } + + val candidateKeys = + jwks.jsonWebKeys.filter { + (keyId == null || keyId == it.keyId) && isJwkCompatibleWithAlgorithm(it, algorithm) + } + + if (candidateKeys.isEmpty()) { + return if (keyId != null) { + ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.no-compatible-key-with-kid", keyId), + false, + ) + } else { + ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.no-compatible-key"), + false, + ) + } + } + + candidateKeys.forEach { jsonWebKey -> + try { + if (verifySignature(encodedJwt, jsonWebKey.key)) { + return if (jsonWebKey.keyId != null) { + ValidationOutcome( + UiToolsBundle.message( + "jwt-encoder-decoder.validation.signature.valid-using-key", + jsonWebKey.keyId, + ), + true, + ) + } else { + ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.signature.valid"), + true, + ) + } + } + } catch (_: Exception) { + // Ignore incompatible keys from the JWKS and continue with the next candidate. + } + } + + return ValidationOutcome( + UiToolsBundle.message("jwt-encoder-decoder.validation.signature.invalid"), + false, + ) + } + + private fun verifySignature(encodedJwt: String, key: Key): Boolean = + JsonWebSignature() + .apply { + setCompactSerialization(encodedJwt) + setKey(key) + isDoKeyValidation = strictKeyValidation.get() + } + .verifySignature() + + private fun readVerificationKey(keyInput: String, algorithm: SignatureAlgorithm): Key = + if (keyInput.startsWith("{")) { + JsonWebKey.Factory.newJwk(keyInput).key + } else { + algorithm.kind.keyFactory!!.generatePublic(X509EncodedKeySpec(toRawKey(keyInput))) + } + + private fun isJwkCompatibleWithAlgorithm( + jsonWebKey: JsonWebKey, + algorithm: SignatureAlgorithm, + ): Boolean { + val algorithmMatches = + jsonWebKey.algorithm == null || jsonWebKey.algorithm == algorithm.jwtHeaderValue + val useMatches = jsonWebKey.use == null || jsonWebKey.use == "sig" + val keyTypeMatches = + when (algorithm.kind) { + SignatureAlgorithmKind.NONE -> false + SignatureAlgorithmKind.HMAC -> jsonWebKey.keyType == "oct" + SignatureAlgorithmKind.RSA -> jsonWebKey.keyType == "RSA" + SignatureAlgorithmKind.ECDSA -> jsonWebKey.keyType == "EC" + } + return algorithmMatches && useMatches && keyTypeMatches + } + + private fun toRawKey(keyInput: String): ByteArray = + Base64.getDecoder().decode(keyInput.replace(JwtEncoderDecoder.rawKeyRegex, "")) + + private fun rootCauseMessage(exception: Throwable): String { + val rootCause = ExceptionUtil.getRootCause(exception) + return rootCause.message ?: rootCause::class.simpleName ?: "unknown" + } + + private fun setNeutralResult() { + resultState.set(ValidationResultState.NONE) + validResultMessage.set("") + invalidResultMessage.set("") + } + + private fun setValidResult(message: String) { + resultState.set(ValidationResultState.VALID) + validResultMessage.set(" $message") + invalidResultMessage.set("") + } + + private fun setInvalidResult(message: String) { + resultState.set(ValidationResultState.INVALID) + validResultMessage.set("") + invalidResultMessage.set(" $message") + } + + private data class ValidationOutcome(val message: String, val valid: Boolean) +} diff --git a/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties b/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties index 637cbb9..e022a69 100644 --- a/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties +++ b/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties @@ -173,4 +173,100 @@ cron-expression.field-constraints.special.no-specific-value=?: No s cron-expression.field-constraints.special.asterix=*: Every possible value. For example: every minute, every hour, etc. cron-expression.field-constraints.description=Allowed characters:
    {0}
cron-expression.field-formats=Field formats: Single value (e.g., 5); List (e.g., 1,5,10); Range (e.g., 1-5); Step values (e.g., */5) - +jwt-encoder-decoder.menu-title=JSON Web Token (JWT) +jwt-encoder-decoder.content-title=JSON Web Token (JWT) Decoder/Encoder/Validator +jwt-encoder-decoder.tab.decode-encode=Decode / Encode +jwt-encoder-decoder.tab.validate=Validate +jwt-encoder-decoder.button.decode=\u25bc Decode +jwt-encoder-decoder.button.encode=\u25b2 Encode +jwt-encoder-decoder.signature-configuration.title=Signature Algorithm Configuration +jwt-encoder-decoder.signature-configuration.algorithm=Algorithm: +jwt-encoder-decoder.signature-configuration.secret-key=Secret key: +jwt-encoder-decoder.signature-configuration.encoding=Encoding +jwt-encoder-decoder.signature-configuration.private-key=Private key: +jwt-encoder-decoder.strict-key-validation=Strict key requirements validation +jwt-encoder-decoder.strict-key-validation.help=The RFC 7518 for the JSON Web Algorithms (JWA) specifies some restrictions that a key or secret should fulfill for the computation of a signature (e.g., a minimum length). This option can be used to enforce these restrictions. +jwt-encoder-decoder.validation.key-source=Key source: +jwt-encoder-decoder.validation.public-key-or-jwk=Public key or JWK: +jwt-encoder-decoder.validation.jwks-url=JWKS URL: +jwt-encoder-decoder.validation.fetch=Fetch +jwt-encoder-decoder.validation.jwks-json=JWKS JSON: +jwt-encoder-decoder.editor.encoded=Encoded +jwt-encoder-decoder.editor.header=Header +jwt-encoder-decoder.editor.payload=Payload +jwt-encoder-decoder.editor.jwt-input=JWT input: +jwt-encoder-decoder.editor.jwt-input-output=JWT input/output: +jwt-encoder-decoder.validation.token-metadata.default=Validation uses the compact JWT shown on the left. +jwt-encoder-decoder.validation.fetch-jwks.in-progress=Fetching JWKS... +jwt-encoder-decoder.validation.fetch-jwks.in-progress-title=Fetching JWKS +jwt-encoder-decoder.validation.fetch-jwks.enter-url=Enter a JWKS URL before fetching. +jwt-encoder-decoder.validation.fetch-jwks.failed=Failed to fetch JWKS: {0} +jwt-encoder-decoder.validation.token-metadata.unknown=Unable to determine token metadata. +jwt-encoder-decoder.validation.invalid-compact-jwt.header-payload=Invalid compact JWT. Expected at least header and payload. +jwt-encoder-decoder.validation.missing-or-unsupported-alg-header=Missing or unsupported ''alg'' header value. +jwt-encoder-decoder.validation.token-metadata.algorithm=Algorithm: {0} +jwt-encoder-decoder.validation.token-metadata.kid-suffix= | kid: {0} +jwt-encoder-decoder.validation.valid-unsecured-jwt=Valid unsecured JWT (alg=none). +jwt-encoder-decoder.validation.invalid-jwt.alg-none-signature=Invalid JWT. Tokens with alg=none must not contain a signature. +jwt-encoder-decoder.validation.invalid-compact-jwt.signature=Invalid compact JWT. Signed tokens must contain a signature part. +jwt-encoder-decoder.validation.failed=Validation failed: {0} +jwt-encoder-decoder.validation.key-source-mismatch.public-key-or-jwks=Key source mismatch. {0} requires a public key or JWKS. +jwt-encoder-decoder.validation.signature.valid=Signature valid. +jwt-encoder-decoder.validation.signature.invalid=Signature invalid. +jwt-encoder-decoder.validation.key-source-mismatch.shared-secret=Key source mismatch. {0} requires a shared secret. +jwt-encoder-decoder.validation.provide-public-key-or-jwk=Provide a public key or JWK. +jwt-encoder-decoder.validation.read-public-key-or-jwk.failed=Failed to read public key or JWK: {0} +jwt-encoder-decoder.validation.provide-jwks-json=Provide JWKS JSON or fetch it from the configured JWKS URL. +jwt-encoder-decoder.validation.parse-jwks.failed=Failed to parse JWKS: {0} +jwt-encoder-decoder.validation.no-compatible-key-with-kid=No compatible key with kid ''{0}'' found in the JWKS. +jwt-encoder-decoder.validation.no-compatible-key=No compatible verification key found in the JWKS. +jwt-encoder-decoder.validation.signature.valid-using-key=Signature valid using key ''{0}''. +jwt-encoder-decoder.signature-algorithm.none=None +jwt-encoder-decoder.signature-algorithm.named={0} ({1}) +jwt-encoder-decoder.secret-key-encoding-mode.raw=Raw +jwt-encoder-decoder.secret-key-encoding-mode.base32=Base32 Encoded +jwt-encoder-decoder.secret-key-encoding-mode.base64=Base64 Encoded +jwt-encoder-decoder.validation-key-source.secret=Shared Secret +jwt-encoder-decoder.validation-key-source.public-key=Public Key / JWK +jwt-encoder-decoder.validation-key-source.jwks=JWKS +jwt-encoder-decoder.standard-claim.tooltip={0} ({1})
{2} +jwt-encoder-decoder.standard-claim.typ.title=Type +jwt-encoder-decoder.standard-claim.typ.description=Indicating that this token is a JWT. +jwt-encoder-decoder.standard-claim.iss.title=Issuer +jwt-encoder-decoder.standard-claim.iss.description=The issuer of the JWT. +jwt-encoder-decoder.standard-claim.sub.title=Subject +jwt-encoder-decoder.standard-claim.sub.description=The subject of the JWT. +jwt-encoder-decoder.standard-claim.aud.title=Audience +jwt-encoder-decoder.standard-claim.aud.description=The recipient of the JWT. +jwt-encoder-decoder.standard-claim.exp.title=Expiration Time +jwt-encoder-decoder.standard-claim.exp.description=JWT expiration time. +jwt-encoder-decoder.standard-claim.nbf.title=Not Before Time +jwt-encoder-decoder.standard-claim.nbf.description=JWT valid after this time. +jwt-encoder-decoder.standard-claim.iat.title=Issued at Time +jwt-encoder-decoder.standard-claim.iat.description=JWT issued at this time. +jwt-encoder-decoder.standard-claim.jti.title=JWT ID +jwt-encoder-decoder.standard-claim.jti.description=A unique identifier for the JWT. +jwt-encoder-decoder.standard-claim.alg.title=Algorithm +jwt-encoder-decoder.standard-claim.alg.description=The algorithm to calculate the signature of this JWT. +jwt-encoder-decoder.standard-claim.azp.title=Authorized Party +jwt-encoder-decoder.standard-claim.azp.description=The party to which the JWT was issued. +jwt-encoder-decoder.standard-claim.sid.title=Session ID +jwt-encoder-decoder.standard-claim.sid.description=An unique session ID. +jwt-encoder-decoder.standard-claim.nonce.title=Nonce +jwt-encoder-decoder.standard-claim.nonce.description=A value used to associate a client session with this JWT. +jwt-encoder-decoder.standard-claim.at-hash.title=Access Token Hash Value +jwt-encoder-decoder.standard-claim.at-hash.description=The hash of an access token. +jwt-encoder-decoder.standard-claim.c-hash.title=Code Hash Value +jwt-encoder-decoder.standard-claim.c-hash.description=The hash of a code. +jwt-encoder-decoder.standard-claim.act.title=Actor +jwt-encoder-decoder.standard-claim.act.description=The hash of an access token. +jwt-encoder-decoder.standard-claim.auth-time.title=Authentication Time +jwt-encoder-decoder.standard-claim.auth-time.description=Time of user authentication. +jwt-encoder-decoder.standard-claim.scope.title=Scope +jwt-encoder-decoder.standard-claim.scope.description=Permissions granted to the token. +jwt-encoder-decoder.encode.signature-unavailable.header-payload-errors=Unable to compute signature due to header or payload errors +jwt-encoder-decoder.encode.signature-unavailable.configuration-errors=Unable to compute signature due signature configuration errors +jwt-encoder-decoder.header.unsupported-algorithm=Unsupported algorithm: ''{0}'' +jwt-encoder-decoder.header.missing-algorithm=Missing algorithm header field: ''alg'' +jwt-encoder-decoder.signature.compute-failed=Failed to compute signature: +jwt-encoder-decoder.signature.private-key-required=A private key must be provided diff --git a/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties b/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties index 0f79156..a50d7c6 100644 --- a/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties +++ b/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties @@ -173,3 +173,100 @@ cron-expression.field-constraints.special.no-specific-value=?: Kein cron-expression.field-constraints.special.asterix=*: Jeden mglichen Wert. Z.B. jede Minute, jede Stunde usw. cron-expression.field-constraints.description=Erlaubte Zeichen:
    {0}
cron-expression.field-formats=Feldformate: einzelner Wert (z.B. 5); Liste (z.B. 1,5,10); Bereich (z.B. 1-5); Schrittweite (z.B. */5) +jwt-encoder-decoder.menu-title=JSON Web Token (JWT) +jwt-encoder-decoder.content-title=JSON Web Token (JWT) Dekodierer/Kodierer/Validator +jwt-encoder-decoder.tab.decode-encode=Dekodieren / Kodieren +jwt-encoder-decoder.tab.validate=Validieren +jwt-encoder-decoder.button.decode=\u25bc Dekodieren +jwt-encoder-decoder.button.encode=\u25b2 Kodieren +jwt-encoder-decoder.signature-configuration.title=Signaturalgorithmus-Konfiguration +jwt-encoder-decoder.signature-configuration.algorithm=Algorithmus: +jwt-encoder-decoder.signature-configuration.secret-key=Geheimer Schl\u00fcssel: +jwt-encoder-decoder.signature-configuration.encoding=Kodierung +jwt-encoder-decoder.signature-configuration.private-key=Privater Schl\u00fcssel: +jwt-encoder-decoder.strict-key-validation=Strikte Validierung der Schl\u00fcsselanforderungen +jwt-encoder-decoder.strict-key-validation.help=Die RFC 7518 f\u00fcr JSON Web Algorithms (JWA) beschreibt einige Einschr\u00e4nkungen, die ein Schl\u00fcssel oder Geheimnis f\u00fcr die Berechnung einer Signatur erf\u00fcllen sollte, zum Beispiel eine Mindestl\u00e4nge. Mit dieser Option lassen sich diese Einschr\u00e4nkungen erzwingen. +jwt-encoder-decoder.validation.key-source=Schl\u00fcsselquelle: +jwt-encoder-decoder.validation.public-key-or-jwk=\u00d6ffentlicher Schl\u00fcssel oder JWK: +jwt-encoder-decoder.validation.jwks-url=JWKS-URL: +jwt-encoder-decoder.validation.fetch=Abrufen +jwt-encoder-decoder.validation.jwks-json=JWKS-JSON: +jwt-encoder-decoder.editor.encoded=Kodiert +jwt-encoder-decoder.editor.header=Header +jwt-encoder-decoder.editor.payload=Payload +jwt-encoder-decoder.editor.jwt-input=JWT-Eingabe: +jwt-encoder-decoder.editor.jwt-input-output=JWT-Eingabe/Ausgabe: +jwt-encoder-decoder.validation.token-metadata.default=Zur Validierung wird das kompakte JWT auf der linken Seite verwendet. +jwt-encoder-decoder.validation.fetch-jwks.in-progress=JWKS wird abgerufen... +jwt-encoder-decoder.validation.fetch-jwks.in-progress-title=JWKS wird abgerufen +jwt-encoder-decoder.validation.fetch-jwks.enter-url=Geben Sie vor dem Abrufen eine JWKS-URL ein. +jwt-encoder-decoder.validation.fetch-jwks.failed=JWKS konnte nicht abgerufen werden: {0} +jwt-encoder-decoder.validation.token-metadata.unknown=Token-Metadaten konnten nicht ermittelt werden. +jwt-encoder-decoder.validation.invalid-compact-jwt.header-payload=Ung\u00fcltiges kompaktes JWT. Mindestens Header und Payload werden erwartet. +jwt-encoder-decoder.validation.missing-or-unsupported-alg-header=Fehlender oder nicht unterst\u00fctzter ''alg''-Headerwert. +jwt-encoder-decoder.validation.token-metadata.algorithm=Algorithmus: {0} +jwt-encoder-decoder.validation.token-metadata.kid-suffix= | kid: {0} +jwt-encoder-decoder.validation.valid-unsecured-jwt=G\u00fcltiges ungesichertes JWT (alg=none). +jwt-encoder-decoder.validation.invalid-jwt.alg-none-signature=Ung\u00fcltiges JWT. Tokens mit alg=none d\u00fcrfen keine Signatur enthalten. +jwt-encoder-decoder.validation.invalid-compact-jwt.signature=Ung\u00fcltiges kompaktes JWT. Signierte Tokens m\u00fcssen einen Signaturteil enthalten. +jwt-encoder-decoder.validation.failed=Validierung fehlgeschlagen: {0} +jwt-encoder-decoder.validation.key-source-mismatch.public-key-or-jwks=Nicht passende Schl\u00fcsselquelle. {0} erfordert einen \u00f6ffentlichen Schl\u00fcssel oder JWKS. +jwt-encoder-decoder.validation.signature.valid=Signatur ist g\u00fcltig. +jwt-encoder-decoder.validation.signature.invalid=Signatur ist ung\u00fcltig. +jwt-encoder-decoder.validation.key-source-mismatch.shared-secret=Nicht passende Schl\u00fcsselquelle. {0} erfordert ein gemeinsames Geheimnis. +jwt-encoder-decoder.validation.provide-public-key-or-jwk=Geben Sie einen \u00f6ffentlichen Schl\u00fcssel oder JWK an. +jwt-encoder-decoder.validation.read-public-key-or-jwk.failed=\u00d6ffentlicher Schl\u00fcssel oder JWK konnte nicht gelesen werden: {0} +jwt-encoder-decoder.validation.provide-jwks-json=Geben Sie JWKS-JSON an oder rufen Sie es von der konfigurierten JWKS-URL ab. +jwt-encoder-decoder.validation.parse-jwks.failed=JWKS konnte nicht geparst werden: {0} +jwt-encoder-decoder.validation.no-compatible-key-with-kid=Kein kompatibler Schl\u00fcssel mit kid ''{0}'' wurde im JWKS gefunden. +jwt-encoder-decoder.validation.no-compatible-key=Kein kompatibler Verifizierungsschl\u00fcssel wurde im JWKS gefunden. +jwt-encoder-decoder.validation.signature.valid-using-key=Signatur ist mit Schl\u00fcssel ''{0}'' g\u00fcltig. +jwt-encoder-decoder.signature-algorithm.none=Kein +jwt-encoder-decoder.signature-algorithm.named={0} ({1}) +jwt-encoder-decoder.secret-key-encoding-mode.raw=Roh +jwt-encoder-decoder.secret-key-encoding-mode.base32=Base32-kodiert +jwt-encoder-decoder.secret-key-encoding-mode.base64=Base64-kodiert +jwt-encoder-decoder.validation-key-source.secret=Gemeinsames Geheimnis +jwt-encoder-decoder.validation-key-source.public-key=\u00d6ffentlicher Schl\u00fcssel / JWK +jwt-encoder-decoder.validation-key-source.jwks=JWKS +jwt-encoder-decoder.standard-claim.tooltip={0} ({1})
{2} +jwt-encoder-decoder.standard-claim.typ.title=Typ +jwt-encoder-decoder.standard-claim.typ.description=Zeigt an, dass dieses Token ein JWT ist. +jwt-encoder-decoder.standard-claim.iss.title=Aussteller +jwt-encoder-decoder.standard-claim.iss.description=Der Aussteller des JWT. +jwt-encoder-decoder.standard-claim.sub.title=Subjekt +jwt-encoder-decoder.standard-claim.sub.description=Das Subjekt des JWT. +jwt-encoder-decoder.standard-claim.aud.title=Empf\u00e4nger +jwt-encoder-decoder.standard-claim.aud.description=Der Empf\u00e4nger des JWT. +jwt-encoder-decoder.standard-claim.exp.title=Ablaufzeit +jwt-encoder-decoder.standard-claim.exp.description=Ablaufzeit des JWT. +jwt-encoder-decoder.standard-claim.nbf.title=G\u00fcltig ab +jwt-encoder-decoder.standard-claim.nbf.description=JWT ist nach diesem Zeitpunkt g\u00fcltig. +jwt-encoder-decoder.standard-claim.iat.title=Ausstellungszeit +jwt-encoder-decoder.standard-claim.iat.description=JWT wurde zu diesem Zeitpunkt ausgestellt. +jwt-encoder-decoder.standard-claim.jti.title=JWT-ID +jwt-encoder-decoder.standard-claim.jti.description=Eine eindeutige Kennung f\u00fcr das JWT. +jwt-encoder-decoder.standard-claim.alg.title=Algorithmus +jwt-encoder-decoder.standard-claim.alg.description=Der Algorithmus zur Berechnung der Signatur dieses JWT. +jwt-encoder-decoder.standard-claim.azp.title=Autorisierte Partei +jwt-encoder-decoder.standard-claim.azp.description=Die Partei, f\u00fcr die das JWT ausgestellt wurde. +jwt-encoder-decoder.standard-claim.sid.title=Sitzungs-ID +jwt-encoder-decoder.standard-claim.sid.description=Eine eindeutige Sitzungs-ID. +jwt-encoder-decoder.standard-claim.nonce.title=Nonce +jwt-encoder-decoder.standard-claim.nonce.description=Ein Wert, der eine Client-Sitzung mit diesem JWT verkn\u00fcpft. +jwt-encoder-decoder.standard-claim.at-hash.title=Access-Token-Hashwert +jwt-encoder-decoder.standard-claim.at-hash.description=Der Hash eines Access Tokens. +jwt-encoder-decoder.standard-claim.c-hash.title=Code-Hashwert +jwt-encoder-decoder.standard-claim.c-hash.description=Der Hash eines Codes. +jwt-encoder-decoder.standard-claim.act.title=Akteur +jwt-encoder-decoder.standard-claim.act.description=Der Hash eines Access Tokens. +jwt-encoder-decoder.standard-claim.auth-time.title=Authentifizierungszeit +jwt-encoder-decoder.standard-claim.auth-time.description=Zeitpunkt der Benutzer-Authentifizierung. +jwt-encoder-decoder.standard-claim.scope.title=Berechtigungsumfang +jwt-encoder-decoder.standard-claim.scope.description=Dem Token gew\u00e4hrte Berechtigungen. +jwt-encoder-decoder.encode.signature-unavailable.header-payload-errors=Signatur kann aufgrund von Header- oder Payload-Fehlern nicht berechnet werden +jwt-encoder-decoder.encode.signature-unavailable.configuration-errors=Signatur kann aufgrund von Fehlern in der Signaturkonfiguration nicht berechnet werden +jwt-encoder-decoder.header.unsupported-algorithm=Nicht unterst\u00fctzter Algorithmus: ''{0}'' +jwt-encoder-decoder.header.missing-algorithm=Fehlendes Algorithmus-Header-Feld: ''alg'' +jwt-encoder-decoder.signature.compute-failed=Signatur konnte nicht berechnet werden: +jwt-encoder-decoder.signature.private-key-required=Ein privater Schl\u00fcssel muss angegeben werden diff --git a/modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPaneTest.kt b/modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPaneTest.kt new file mode 100644 index 0000000..428c2a2 --- /dev/null +++ b/modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPaneTest.kt @@ -0,0 +1,29 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common + +import com.intellij.openapi.ui.getUserData +import com.intellij.openapi.ui.putUserData +import com.intellij.openapi.util.Key +import com.intellij.testFramework.junit5.RunMethodInEdt +import javax.swing.JPanel +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class TitledTabbedPaneTest { + @Test + @RunMethodInEdt(writeIntent = RunMethodInEdt.WriteIntentMode.True) + fun `on selection changed passes the original wrapped tab content`() { + val selectionKey = Key.create("selectionKey") + val firstTab = JPanel().apply { putUserData(selectionKey, "first") } + val secondTab = JPanel().apply { putUserData(selectionKey, "second") } + val selections = mutableListOf() + + val titledTabbedPane = + TitledTabbedPane("Title", listOf("First" to firstTab, "Second" to secondTab)).apply { + onSelectionChanged { selections.add(checkNotNull(it.getUserData(selectionKey))) } + } + + titledTabbedPane.selectedIndex = 2 + + assertThat(selections).containsExactly("second") + } +} diff --git a/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt b/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt index 19f5c3c..ea03c11 100644 --- a/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt +++ b/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt @@ -11,7 +11,7 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolCon import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiTool import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolFactory import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolFactoryEp -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.JwtEncoderDecoder +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.jwtencoderdecoder.SignatureAlgorithm import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.transformer.HmacTransformer import java.math.BigDecimal import java.security.Security @@ -85,8 +85,7 @@ open class DeveloperUiToolUnderTest( id == "date-time-converter" && property.key == "timeZoneId" -> ZoneId.getAvailableZoneIds().random { it == property.defaultValue } - id == "jwt-encoder-decoder" && property.key == "algorithm" -> - JwtEncoderDecoder.SignatureAlgorithm.HMAC512 + id == "jwt-encoder-decoder" && property.key == "algorithm" -> SignatureAlgorithm.HMAC512 id == "jwt-encoder-decoder" && property.key == "encodedText" -> "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.ANCf_8p1AE4ZQs7QuqGAyyfTEgYrKSjKWkhBk5cIn1_2QVr2jEjmM-1tu7EgnyOf_fAsvdFXva8Sv05iTGzETg" id == "jwt-encoder-decoder" && property.key == "headerText" -> diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 2b9b468..f811f45 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -145,7 +145,7 @@ + implementationClass="dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.jwtencoderdecoder.JwtEncoderDecoder$Factory"/> - \ No newline at end of file + From 21d4750dcade96debab65b765c7b6ca686831858 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Thu, 30 Apr 2026 23:21:39 +0200 Subject: [PATCH 03/11] feat: Added new tool "HTTP Server" to start and manage an easily configurable local HTTP server. --- CHANGELOG.md | 2 +- .../tool/ui/common/AdvancedEditor.kt | 41 + .../tool/ui/common/TitledTabbedPane.kt | 2 +- .../tool/ui/common/UiExtensions.kt | 4 + .../jwtencoderdecoder/JwtEncoderDecoder.kt | 1 + .../tool/ui/frame/AboutPluginDialog.kt | 4 + .../ui/other/ExternalSystemProcessRegistry.kt | 114 ++ .../tool/ui/other/HttpServer.kt | 1075 +++++++++++++++++ .../tool/ui/other/RegularExpressionMatcher.kt | 3 + .../tool/ui/other/TextStatistic.kt | 4 + .../message/UiToolsBundle.properties | 56 +- .../message/UiToolsBundle_de.properties | 68 +- .../ExternalSystemProcessRegistryTest.kt | 84 ++ src/main/resources/META-INF/plugin.xml | 9 + 14 files changed, 1455 insertions(+), 12 deletions(-) create mode 100644 modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistry.kt create mode 100644 modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt create mode 100644 modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistryTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb020e..a3928f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ ### Added +- Added new tool "HTTP Server" to start and manage an easily configurable local HTTP server. - Added support for algo=none to the JWT tool. - Added support for validating JWTs via public keys and JWKS. - Overhauled the JWT tool UI. - ### Changed ### Removed diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/AdvancedEditor.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/AdvancedEditor.kt index 5eb0696..f2c2b7a 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/AdvancedEditor.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/AdvancedEditor.kt @@ -12,11 +12,14 @@ import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ex.ClipboardUtil import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.command.CommandProcessor import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.ScrollType import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.event.DocumentListener @@ -170,6 +173,27 @@ class AdvancedEditor( return this } + fun appendText(value: String) { + if (value.isEmpty()) { + return + } + + updateDocument { + val document = editor.document + document.insertString(document.textLength, value) + editor.caretModel.moveToOffset(document.textLength) + editor.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE) + } + } + + fun clearText() { + updateDocument { + editor.document.setText("") + editor.caretModel.moveToOffset(0) + editor.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE) + } + } + fun onFocusGained(changeListener: () -> Unit): AdvancedEditor { onFocusGained = changeListener return this @@ -406,6 +430,23 @@ class AdvancedEditor( } } + private fun updateDocument(update: () -> Unit) { + val application = ApplicationManager.getApplication() + val action = Runnable { + if (editor.isDisposed) { + return@Runnable + } + + CommandProcessor.getInstance().runUndoTransparentAction { runWriteAction(update) } + } + + if (application.isDispatchThread) { + action.run() + } else { + application.invokeLater(action) + } + } + private fun EditorEx.syncEditorColors() { setBackgroundColor(null) // To use background from set color scheme diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt index 4a78317..52d95a4 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt @@ -14,7 +14,7 @@ class TitledTabbedPane(title: String, tabs: List>) : JB // -- Initialization ------------------------------------------------------ // init { - tabComponentInsets = JBUI.emptyInsets() + applyDefaultTabComponentInsets() addTab("", JPanel()) setTabComponentAt(0, JBLabel(title).apply { font = font?.deriveFont(Font.BOLD) }) diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt index 38e9c4e..4a8dd5a 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/UiExtensions.kt @@ -311,6 +311,10 @@ fun JBTabbedPane.onSelectionChanged(onSelectionChanged: (JComponent) -> Unit): J return this } +fun JBTabbedPane.applyDefaultTabComponentInsets() { + tabComponentInsets = JBUI.insetsTop(5) +} + // -- Private Methods ---------------------------------------------------- // // -- Inner Type ---------------------------------------------------------- // diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt index 554df5a..ba2421e 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt @@ -52,6 +52,7 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEd import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.PropertyComponentPredicate import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.SimpleToggleAction import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.UiUtils +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.applyDefaultTabComponentInsets import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.onSelectionChanged import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.registerDynamicToolTip import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.setValidationResultBorder diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt index f3083cb..e33faf4 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt @@ -10,7 +10,9 @@ import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.TopGap import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBEmptyBorder +import com.intellij.util.ui.JBUI import dev.turingcomplete.intellijdevelopertoolsplugin.common.PluginInfo +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.applyDefaultTabComponentInsets import java.awt.Dimension import javax.swing.Action import javax.swing.JComponent @@ -36,6 +38,8 @@ class AboutPluginDialog(project: Project?, parentComponent: JComponent) : cell( JBTabbedPane().apply { + applyDefaultTabComponentInsets() + tabs.forEach { (title, component) -> // Create scroll panes with specific preferred size val scrollPane = ScrollPaneFactory.createScrollPane(component, true) diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistry.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistry.kt new file mode 100644 index 0000000..d48ff5b --- /dev/null +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistry.kt @@ -0,0 +1,114 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.other + +import com.intellij.execution.process.KillableProcessHandler +import com.intellij.execution.process.ProcessHandler +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project + +@Service(Service.Level.APP) +class ExternalSystemProcessRegistry : Disposable { + // -- Properties ---------------------------------------------------------- // + + private val log = logger() + private val lock = Any() + private val projectsByProcess = LinkedHashMap() + private val processesByProject = LinkedHashMap>() + + // -- Initialization ------------------------------------------------------ // + // -- Exposed Methods ----------------------------------------------------- // + + fun register(project: Project?, process: ProcessHandler) { + synchronized(lock) { + projectsByProcess.put(process, project)?.let { previousProject -> + removeTrackedProcess(process, previousProject) + } + processesByProject.getOrPut(project) { linkedSetOf() }.add(process) + } + } + + fun unregister(process: ProcessHandler) { + synchronized(lock) { + projectsByProcess.remove(process)?.let { project -> removeTrackedProcess(process, project) } + } + } + + fun stopProcesses(project: Project) { + trackedProcesses(project).forEach { stopTrackedProcess(it) } + } + + override fun dispose() { + trackedProcesses().forEach { stopTrackedProcess(it) } + } + + // -- Private Methods ----------------------------------------------------- // + + private fun trackedProcesses(project: Project): List = + synchronized(lock) { + processesByProject.remove(project)?.toList().orEmpty().onEach { projectsByProcess.remove(it) } + } + + private fun trackedProcesses(): List = + synchronized(lock) { + projectsByProcess.keys.toList().also { + projectsByProcess.clear() + processesByProject.clear() + } + } + + private fun removeTrackedProcess(process: ProcessHandler, project: Project?) { + processesByProject[project]?.apply { + remove(process) + if (isEmpty()) { + processesByProject.remove(project) + } + } + } + + private fun stopTrackedProcess(process: ProcessHandler) { + if (process.isProcessTerminated) { + return + } + + runCatching { + process.destroyProcess() + if (!process.waitFor(STOP_TIMEOUT_MILLISECONDS)) { + if (process is KillableProcessHandler && process.canKillProcess()) { + process.killProcess() + } + if (!process.waitFor(STOP_TIMEOUT_MILLISECONDS)) { + throw IllegalStateException("Timed out while stopping tracked HttpServer process") + } + } + } + .onFailure { error -> log.warn("Failed to stop tracked HttpServer process", error) } + } + + // -- Inner Type ---------------------------------------------------------- // + // -- Companion Object ---------------------------------------------------- // + + companion object { + + private const val STOP_TIMEOUT_MILLISECONDS = 5_000L + } +} + +@Service(Service.Level.PROJECT) +class HttpServerProjectProcessShutdownService(private val project: Project) : Disposable { + // -- Properties ---------------------------------------------------------- // + // -- Initialization ------------------------------------------------------ // + // -- Exposed Methods ----------------------------------------------------- // + + override fun dispose() { + ApplicationManager.getApplication() + .service() + .stopProcesses(project) + } + + // -- Private Methods ----------------------------------------------------- // + // -- Inner Type ---------------------------------------------------------- // + // -- Companion Object ---------------------------------------------------- // +} diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt new file mode 100644 index 0000000..33ff5e6 --- /dev/null +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt @@ -0,0 +1,1075 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.other + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.KillableProcessHandler +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.json.JsonLanguage +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.Key +import com.intellij.ui.HyperlinkLabel +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.selected +import com.intellij.util.ui.components.BorderLayoutPanel +import dev.turingcomplete.intellijdevelopertoolsplugin.common.OkHttpClientUtils.applyIntelliJProxySettings +import dev.turingcomplete.intellijdevelopertoolsplugin.common.ValueProperty +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration +import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.CONFIGURATION +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.ObjectMapperService +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiTool +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolContext +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolFactory +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolPresentation +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.PropertyComponentPredicate +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.applyDefaultTabComponentInsets +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.bind +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.bindIntTextImproved +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.validateLongValue +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.message.UiToolsBundle +import okhttp3.OkHttpClient +import okhttp3.Request +import java.awt.Dimension +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.event.HyperlinkEvent + +class HttpServer( + private val configuration: DeveloperToolConfiguration, + private val project: Project?, + private val context: DeveloperUiToolContext, + parentDisposable: Disposable, +) : DeveloperUiTool(parentDisposable) { + // -- Properties ---------------------------------------------------------- // + + private val log = logger() + private val content = BorderLayoutPanel() + private val serverStatus = + ValueProperty(UiToolsBundle.message("http-server.server.status.stopped")) + private val serverUrl = ValueProperty("") + private val serverRunning = ValueProperty(false) + private val serverBusy = ValueProperty(false) + private val restartRequired = ValueProperty(false) + + private val serverPort = + configuration.register("serverPort", DEFAULT_WIREMOCK_PORT, CONFIGURATION) + private val verboseLogging = configuration.register("verboseLogging", false, CONFIGURATION) + private val printAllNetworkTraffic = + configuration.register("printAllNetworkTraffic", false, CONFIGURATION) + private val advancedCommandLineOptions = + configuration.register( + "advancedCommandLineOptions", + DEFAULT_ADVANCED_COMMAND_LINE_OPTIONS, + CONFIGURATION, + ) + private val javaExecutableMode = + configuration.register("javaExecutableMode", JavaExecutableMode.BUILT_IN_JRE, CONFIGURATION) + private val javaExecutablePath = configuration.register("javaExecutablePath", "", CONFIGURATION) + private val serverMode = configuration.register("serverMode", ServerMode.BUILT_IN_SERVER) + private val customRootDirectory = configuration.register("customRootDirectory", "", CONFIGURATION) + private val builtInServerMapping = + configuration.register( + "builtInServerMapping", + "", + CONFIGURATION, + """ + { + "request": { + "method": "GET", + "urlPattern": "/" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "Hello World!" + } + } + """ + .trimIndent(), + ) + + private val builtInServerMappingEditor = + AdvancedEditor( + id = "builtInServerMapping", + context = context, + configuration = configuration, + project = project, + title = null, + editorMode = AdvancedEditor.EditorMode.INPUT, + parentDisposable = parentDisposable, + textProperty = builtInServerMapping, + initialLanguage = JsonLanguage.INSTANCE, + minimumSizeHeight = 220, + ) + private val outputEditor = + AdvancedEditor( + id = "httpServerOutput", + context = context, + configuration = configuration, + project = project, + title = null, + editorMode = AdvancedEditor.EditorMode.OUTPUT, + parentDisposable = parentDisposable, + ) + + private val processLock = Any() + + @Volatile private var wireMockProcess: KillableProcessHandler? = null + private var startedServerMode: ServerMode? = null + private var startedServerPort: Int? = null + private var startedVerboseLogging: Boolean? = null + private var startedPrintAllNetworkTraffic: Boolean? = null + private var startedAdvancedCommandLineOptions: String? = null + private var startedJavaExecutableMode: JavaExecutableMode? = null + private var startedJavaExecutablePath: String? = null + + // -- Initialization ------------------------------------------------------ // + // -- Exposed Methods ----------------------------------------------------- // + + init { + serverPort.afterChange(parentDisposable) { updateRestartWarning() } + verboseLogging.afterChange(parentDisposable) { updateRestartWarning() } + printAllNetworkTraffic.afterChange(parentDisposable) { updateRestartWarning() } + advancedCommandLineOptions.afterChange(parentDisposable) { updateRestartWarning() } + javaExecutableMode.afterChange(parentDisposable) { updateRestartWarning() } + javaExecutablePath.afterChange(parentDisposable) { updateRestartWarning() } + serverMode.afterChange(parentDisposable) { updateRestartWarning() } + serverRunning.afterChange(parentDisposable) { updateRestartWarning() } + + builtInServerMapping.afterChange(parentDisposable) { syncBuiltInServerMappingsIfNeeded() } + } + + override fun Panel.buildUi() { + row { cell(content).resizableColumn().align(Align.FILL) }.resizableRow() + } + + override fun afterBuildUi() { + syncContent() + } + + // -- Private Methods ----------------------------------------------------- // + + private fun syncContent() { + setContent(if (isWireMockDownloaded()) createConfigurationUi() else createDownloadUi()) + } + + private fun setContent(component: JComponent) { + runOnEdt { + content.removeAll() + content.addToCenter(component) + content.revalidate() + content.repaint() + } + } + + private fun createDownloadUi(): JComponent = panel { + row { cell(createDownloadDescription()).resizableColumn().align(Align.FILL) } + + row { cell(createMavenCentralLink()) }.topGap(TopGap.NONE) + + row { + lateinit var downloadButton: JButton + downloadButton = + button(UiToolsBundle.message("http-server.download.button")) { + downloadWireMockStandalone(downloadButton) + } + .component + } + } + + private fun createDownloadDescription(): JComponent = + JBLabel("${UiToolsBundle.message("http-server.download.description")}") + + private fun createMavenCentralLink(): JComponent = + HyperlinkLabel(UiToolsBundle.message("http-server.download.maven-central-link")).apply { + setHyperlinkTarget(MAVEN_CENTRAL_HTTP_REPOSITORY_URL) + } + + private fun createConfigurationUi(): JComponent = panel { + row { + label("") + .label(UiToolsBundle.message("http-server.server.status.label")) + .bindText(serverStatus) + + button(UiToolsBundle.message("http-server.server.start")) { startWireMock() } + .visibleIf(PropertyComponentPredicate(serverRunning, false)) + .enabledIf(PropertyComponentPredicate(serverBusy, false)) + .gap(RightGap.SMALL) + + button(UiToolsBundle.message("http-server.server.restart")) { restartWireMock() } + .visibleIf(PropertyComponentPredicate(serverRunning, true)) + .enabledIf(PropertyComponentPredicate(serverBusy, false)) + .gap(RightGap.SMALL) + + button(UiToolsBundle.message("http-server.server.stop")) { stopWireMock() } + .visibleIf(PropertyComponentPredicate(serverRunning, true)) + .enabledIf(PropertyComponentPredicate(serverBusy, false)) + .gap(RightGap.SMALL) + + contextHelp(UiToolsBundle.message("http-server.server.auto-reload-info")) + } + + row { cell(createServerUrlLink()) }.visibleIf(PropertyComponentPredicate(serverRunning, true)) + + row { + icon(AllIcons.General.Warning).gap(RightGap.SMALL) + label(UiToolsBundle.message("http-server.server.restart-required")) + } + .visibleIf(PropertyComponentPredicate(restartRequired, true)) + + row { + cell( + JBTabbedPane().apply { + applyDefaultTabComponentInsets() + + addTab( + UiToolsBundle.message("http-server.tab.configuration"), + createConfigurationTab(), + ) + addTab(UiToolsBundle.message("http-server.tab.output"), createOutputTab()) + } + ) + .resizableColumn() + .align(Align.FILL) + } + .resizableRow() + } + + private fun createConfigurationTab(): JComponent = panel { + row { + textField() + .label(UiToolsBundle.message("http-server.server.port.label")) + .bindIntTextImproved(serverPort) + .validateLongValue(LongRange(1, 65_535)) + .columns(6) + } + + row { + checkBox(UiToolsBundle.message("http-server.server.verbose-logging")) + .bindSelected(verboseLogging) + } + + row { + checkBox(UiToolsBundle.message("http-server.server.print-all-network-traffic")) + .bindSelected(printAllNetworkTraffic) + } + + row { cell(createAdvancedConfigLink()) }.topGap(TopGap.NONE) + + buttonsGroup { + row { + val customDirectoryRadioButton = + radioButton(UiToolsBundle.message("http-server.configuration.mode.custom-directory")) + .bind(serverMode, ServerMode.CUSTOM_DIRECTORY) + .gap(RightGap.SMALL) + textFieldWithBrowseButton( + FileChooserDescriptorFactory.createSingleFolderDescriptor() + .withTitle(UiToolsBundle.message("http-server.configuration.custom-directory.title")), + project, + ) + .bindText(customRootDirectory) + .enabledIf(customDirectoryRadioButton.selected) + .resizableColumn() + .align(Align.FILL) + .gap(RightGap.SMALL) + } + + row { + radioButton(UiToolsBundle.message("http-server.configuration.mode.built-in-server")) + .bind(serverMode, ServerMode.BUILT_IN_SERVER) + } + } + + row { cell(builtInServerMappingEditor.component).resizableColumn().align(Align.FILL) } + .resizableRow() + .topGap(TopGap.SMALL) + .visibleIf(PropertyComponentPredicate(serverMode, ServerMode.BUILT_IN_SERVER)) + + row { cell(createBuiltInServerDirectoryLink()) } + .topGap(TopGap.NONE) + .bottomGap(BottomGap.NONE) + .visibleIf(PropertyComponentPredicate(serverMode, ServerMode.BUILT_IN_SERVER)) + + row { cell(createMappingsDocumentationLink()) }.topGap(TopGap.NONE).bottomGap(BottomGap.NONE) + } + + private fun createOutputTab(): JComponent = panel { + row { cell(outputEditor.component).resizableColumn().align(Align.FILL) } + .layout(RowLayout.INDEPENDENT) + .resizableRow() + + row { cell(createClearOutputLink()) } + } + + private fun createClearOutputLink(): JComponent = + HyperlinkLabel(UiToolsBundle.message("http-server.output.clear")).apply { + addHyperlinkListener { event -> + if (event.eventType == HyperlinkEvent.EventType.ACTIVATED) { + clearProcessOutput() + } + } + } + + private fun createMappingsDocumentationLink(): JComponent = + HyperlinkLabel().apply { + setTextWithHyperlink( + UiToolsBundle.message("http-server.configuration.mappings.documentation") + ) + setHyperlinkTarget(WIREMOCK_MAPPINGS_DOCUMENTATION_URL) + } + + private fun createCommandLineOptionsDocumentationLink(): JComponent = + HyperlinkLabel().apply { + setTextWithHyperlink(UiToolsBundle.message("http-server.advanced-config.documentation")) + setHyperlinkTarget(WIREMOCK_COMMAND_LINE_OPTIONS_DOCUMENTATION_URL) + } + + private fun createAdvancedConfigLink(): JComponent = + HyperlinkLabel(UiToolsBundle.message("http-server.server.advanced-config")).apply { + addHyperlinkListener { event -> + if (event.eventType == HyperlinkEvent.EventType.ACTIVATED) { + showAdvancedConfigDialog() + } + } + } + + private fun createBuiltInServerDirectoryLink(): JComponent = + HyperlinkLabel(UiToolsBundle.message("http-server.configuration.built-in-server.directory")) + .apply { + addHyperlinkListener { event -> + if (event.eventType == HyperlinkEvent.EventType.ACTIVATED) { + Files.createDirectories(builtInServerFilesDirectory) + BrowserUtil.browse(builtInServerRootDirectory) + } + } + } + + private fun createServerUrlLink(): JComponent = + HyperlinkLabel().apply { + updateServerUrlLink(serverUrl.get()) + addHyperlinkListener { event -> + if (event.eventType == HyperlinkEvent.EventType.ACTIVATED) { + BrowserUtil.open(serverUrl.get()) + } + } + serverUrl.afterChange(parentDisposable) { updateServerUrlLink(it) } + } + + private fun HyperlinkLabel.updateServerUrlLink(url: String) { + setTextWithHyperlink("$url") + setHyperlinkTarget(url) + } + + private fun downloadWireMockStandalone(downloadButton: JButton) { + object : + Task.Backgroundable(project, UiToolsBundle.message("http-server.download.task-title")) { + + override fun run(indicator: ProgressIndicator) { + setButtonEnabled(downloadButton, false) + indicator.isIndeterminate = true + indicator.text = UiToolsBundle.message("http-server.download.task-title") + + downloadWireMockStandaloneJar() + } + + override fun onSuccess() { + syncContent() + } + + override fun onThrowable(error: Throwable) { + log.warn("Failed to download WireMock standalone to: $wireMockStandaloneJarPath", error) + + Messages.showErrorDialog( + project, + UiToolsBundle.message( + "http-server.download.failed", + "${error::class.simpleName ?: error::class.java.simpleName}: ${error.message ?: "Unknown error"}", + ), + UiToolsBundle.message("http-server.download.failed-title"), + ) + } + + override fun onFinished() { + if (!isWireMockDownloaded()) { + setButtonEnabled(downloadButton, true) + } + } + } + .queue() + } + + private fun downloadWireMockStandaloneJar() { + val httpClient = + OkHttpClient.Builder().applyIntelliJProxySettings(WIREMOCK_STANDALONE_DOWNLOAD_URL).build() + val request = Request.Builder().get().url(WIREMOCK_STANDALONE_DOWNLOAD_URL).build() + + Files.createDirectories(wireMockStandaloneJarPath.parent) + val temporaryDownloadPath = + wireMockStandaloneJarPath.resolveSibling("${wireMockStandaloneJarPath.fileName}.part") + + try { + Files.deleteIfExists(temporaryDownloadPath) + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException( + "Failed to download WireMock standalone from $WIREMOCK_STANDALONE_DOWNLOAD_URL: HTTP ${response.code}" + ) + } + + response.body.byteStream().use { inputStream -> + Files.copy(inputStream, temporaryDownloadPath, StandardCopyOption.REPLACE_EXISTING) + } + } + + Files.move( + temporaryDownloadPath, + wireMockStandaloneJarPath, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (e: Throwable) { + try { + Files.deleteIfExists(temporaryDownloadPath) + } catch (cleanupError: Exception) { + log.warn( + "Failed to delete temporary WireMock download: $temporaryDownloadPath", + cleanupError, + ) + } + + throw e + } + } + + private fun isWireMockDownloaded(): Boolean = + Files.isRegularFile(wireMockStandaloneJarPath) && + runCatching { Files.size(wireMockStandaloneJarPath) > 0L }.getOrDefault(false) + + private fun syncBuiltInServerMappingsIfNeeded() { + if (serverMode.get() != ServerMode.BUILT_IN_SERVER || !isWireMockDownloaded()) { + return + } + + ApplicationManager.getApplication().executeOnPooledThread { + runCatching { prepareBuiltInServerRootDirectory() } + .onFailure { error -> log.warn("Failed to update generated WireMock mappings", error) } + } + } + + private fun startWireMock() { + if (serverBusy.get()) { + return + } + + appendProcessEvent("Starting WireMock") + startOrRestartWireMock( + busyStatus = UiToolsBundle.message("http-server.server.status.starting"), + failureLogMessage = "Failed to start WireMock", + failureTitle = UiToolsBundle.message("http-server.server.start.failed-title"), + createFailureMessage = { errorMessage -> + UiToolsBundle.message("http-server.server.start.failed", errorMessage) + }, + ) + } + + private fun restartWireMock() { + if (serverBusy.get()) { + return + } + + val process = currentWireMockProcess() + if (process == null) { + startWireMock() + return + } + + appendProcessEvent("Restarting WireMock") + startOrRestartWireMock( + processToStop = process, + busyStatus = UiToolsBundle.message("http-server.server.status.restarting"), + failureLogMessage = "Failed to restart WireMock", + failureTitle = UiToolsBundle.message("http-server.server.restart.failed-title"), + createFailureMessage = { errorMessage -> + UiToolsBundle.message("http-server.server.restart.failed", errorMessage) + }, + ) + } + + private fun stopWireMock() { + if (serverBusy.get()) { + return + } + + val process = currentWireMockProcess() ?: return + + setServerBusy(true, UiToolsBundle.message("http-server.server.status.stopping")) + appendProcessEvent("Stopping WireMock") + + ApplicationManager.getApplication().executeOnPooledThread { + try { + stopWireMockProcess(process) + appendProcessEvent("WireMock stopped") + setServerRunning(false, UiToolsBundle.message("http-server.server.status.stopped")) + } catch (e: Exception) { + log.warn("Failed to stop WireMock", e) + appendProcessEvent("Failed to stop WireMock: ${e.message ?: "Unknown error"}") + showWireMockErrorDialog( + UiToolsBundle.message("http-server.server.stop.failed-title"), + UiToolsBundle.message("http-server.server.stop.failed", e.message ?: "Unknown error"), + ) + } finally { + setServerBusy(false) + } + } + } + + private fun startOrRestartWireMock( + processToStop: KillableProcessHandler? = null, + busyStatus: String, + failureLogMessage: String, + failureTitle: String, + createFailureMessage: (String) -> String, + ) { + setServerBusy(true, busyStatus) + + ApplicationManager.getApplication().executeOnPooledThread { + try { + processToStop?.let { stopWireMockProcess(it) } + + val rootDirectory = prepareRootDirectoryForCurrentConfiguration() + startWireMockProcess(rootDirectory) + startedServerMode = serverMode.get() + startedServerPort = serverPort.get() + startedVerboseLogging = verboseLogging.get() + startedPrintAllNetworkTraffic = printAllNetworkTraffic.get() + startedAdvancedCommandLineOptions = advancedCommandLineOptions.get() + startedJavaExecutableMode = javaExecutableMode.get() + startedJavaExecutablePath = javaExecutablePath.get() + + appendProcessEvent("WireMock running at ${currentServerUrl()}") + setServerRunning( + true, + UiToolsBundle.message("http-server.server.status.running"), + currentServerUrl(), + ) + } catch (e: Exception) { + log.warn(failureLogMessage, e) + appendProcessEvent(failureSummaryMessage(e)) + setServerRunning(false, UiToolsBundle.message("http-server.server.status.stopped")) + showWireMockErrorDialog(failureTitle, createFailureMessage(failureDetailedMessage(e))) + } finally { + setServerBusy(false) + } + } + } + + private fun prepareRootDirectoryForCurrentConfiguration(): Path = + when (serverMode.get()) { + ServerMode.CUSTOM_DIRECTORY -> resolveCustomRootDirectory() + ServerMode.BUILT_IN_SERVER -> prepareBuiltInServerRootDirectory() + } + + private fun resolveCustomRootDirectory(): Path { + val customRootDirectory = customRootDirectory.get().trim() + check(customRootDirectory.isNotEmpty()) { + UiToolsBundle.message("http-server.configuration.custom-directory.missing") + } + + val rootDirectory = + try { + Paths.get(customRootDirectory) + } catch (_: InvalidPathException) { + throw IllegalStateException( + UiToolsBundle.message("http-server.configuration.custom-directory.invalid") + ) + } + + if (Files.exists(rootDirectory) && !Files.isDirectory(rootDirectory)) { + throw IllegalStateException( + UiToolsBundle.message("http-server.configuration.custom-directory.invalid") + ) + } + + Files.createDirectories(rootDirectory) + + return rootDirectory + } + + private fun prepareBuiltInServerRootDirectory(): Path { + Files.createDirectories(builtInServerMappingsDirectory) + deleteGeneratedBuiltInServerMappings() + writeJson( + builtInServerMappingsDirectory.resolve("built-in-server-mapping.json"), + builtInServerMapping.get(), + ) + + return builtInServerRootDirectory + } + + private fun deleteGeneratedBuiltInServerMappings() { + Files.list(builtInServerMappingsDirectory).use { mappingFiles -> + mappingFiles.filter { Files.isRegularFile(it) }.forEach { Files.deleteIfExists(it) } + } + } + + private fun writeJson(file: Path, content: Any) { + Files.createDirectories(file.parent) + ObjectMapperService.instance + .jsonMapper() + .writerWithDefaultPrettyPrinter() + .writeValue( + file.toFile(), + if (content is String) { + ObjectMapperService.instance.jsonMapper().readTree(content) + } else { + content + }, + ) + } + + private fun startWireMockProcess(rootDirectory: Path): KillableProcessHandler { + check(isWireMockDownloaded()) { + UiToolsBundle.message("http-server.download.missing-start-blocked") + } + + val javaExecutable = currentJavaExecutable() + val parameters = buildList { + add("-jar") + add(wireMockStandaloneJarPath.toString()) + add("--port") + add(serverPort.get().toString()) + add("--root-dir") + add(rootDirectory.toAbsolutePath().normalize().toString()) + if (serverMode.get() == ServerMode.BUILT_IN_SERVER) { + add("--local-response-templating") + } + if (verboseLogging.get()) { + add("--verbose") + } + if (printAllNetworkTraffic.get()) { + add("--print-all-network-traffic") + } + addAll(parseAdvancedCommandLineOptions()) + } + + val commandLine = + GeneralCommandLine() + .withExePath(javaExecutable.toString()) + .withParameters(parameters) + .withRedirectErrorStream(true) + + appendProcessEvent("Command: ${commandLine.commandLineString}") + + val processOutput = StringBuilder() + val processHandler = KillableProcessHandler(commandLine) + consumeProcessOutput(processHandler, processOutput) + watchWireMockProcess(processHandler) + synchronized(processLock) { wireMockProcess = processHandler } + registerWireMockProcess(processHandler) + processHandler.startNotify() + + if (processHandler.waitFor(STARTUP_TIMEOUT_MILLISECONDS)) { + val exitCode = processHandler.exitCode ?: -1 + unregisterWireMockProcess(processHandler) + synchronized(processLock) { + if (wireMockProcess === processHandler) { + wireMockProcess = null + } + } + throw IllegalStateException( + UiToolsBundle.message( + "http-server.server.start.process-exited", + exitCode, + buildProcessOutputMessage(processOutput), + ) + ) + } + + return processHandler + } + + private fun consumeProcessOutput( + processHandler: KillableProcessHandler, + processOutput: StringBuilder, + ) { + processHandler.addProcessListener( + object : ProcessAdapter() { + + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + val outputChunk = event.text + synchronized(processOutput) { + processOutput.append(outputChunk) + if (processOutput.length > MAX_STARTUP_PROCESS_OUTPUT_LENGTH) { + processOutput.delete(0, processOutput.length - MAX_STARTUP_PROCESS_OUTPUT_LENGTH) + } + } + outputEditor.appendText(outputChunk) + } + } + ) + } + + private fun watchWireMockProcess(processHandler: KillableProcessHandler) { + processHandler.addProcessListener( + object : ProcessAdapter() { + + override fun processTerminated(event: ProcessEvent) { + unregisterWireMockProcess(processHandler) + val wasCurrentProcess = + synchronized(processLock) { + if (wireMockProcess === processHandler) { + wireMockProcess = null + true + } else { + false + } + } + + if (wasCurrentProcess && !serverBusy.get() && !isDisposed) { + appendProcessEvent("WireMock exited with code ${event.exitCode}") + setServerRunning( + false, + UiToolsBundle.message("http-server.server.status.stopped.exit-code", event.exitCode), + ) + } + } + } + ) + } + + private fun stopWireMockProcess(process: KillableProcessHandler) { + synchronized(processLock) { + if (wireMockProcess === process) { + wireMockProcess = null + } + } + unregisterWireMockProcess(process) + + process.destroyProcess() + if (!process.waitFor(STOP_TIMEOUT_MILLISECONDS)) { + if (process.canKillProcess()) { + process.killProcess() + } + if (!process.waitFor(STOP_TIMEOUT_MILLISECONDS)) { + throw IllegalStateException(UiToolsBundle.message("http-server.server.stop.timeout")) + } + } + } + + private fun currentWireMockProcess(): KillableProcessHandler? = + synchronized(processLock) { + val process = wireMockProcess + if (process != null && process.isProcessTerminated) { + wireMockProcess = null + null + } else { + process + } + } + + private fun registerWireMockProcess(process: KillableProcessHandler) { + project?.service() + ApplicationManager.getApplication() + .service() + .register(project, process) + } + + private fun unregisterWireMockProcess(process: KillableProcessHandler) { + ApplicationManager.getApplication().service().unregister(process) + } + + private fun currentJavaExecutable(): Path { + if (javaExecutableMode.get() == JavaExecutableMode.PATH) { + val customJavaExecutable = javaExecutablePath.get().trim() + check(customJavaExecutable.isNotEmpty()) { + UiToolsBundle.message("http-server.advanced-config.java-executable.path.missing") + } + + return try { + Paths.get(customJavaExecutable) + } catch (_: InvalidPathException) { + throw IllegalStateException( + UiToolsBundle.message("http-server.advanced-config.java-executable.path.invalid") + ) + } + } + + val javaExecutableFileName = + if (System.getProperty("os.name").lowercase().contains("win")) "java.exe" else "java" + + return Paths.get(System.getProperty("java.home"), "bin", javaExecutableFileName) + } + + private fun buildProcessOutputMessage(processOutput: StringBuilder): String = + synchronized(processOutput) { + processOutput.toString().trim().ifBlank { + UiToolsBundle.message("http-server.server.process-output.empty") + } + } + + private fun failureSummaryMessage(error: Throwable): String { + val detailedMessage = failureDetailedMessage(error) + val summaryMessage = + detailedMessage.substringBefore("\n").let { firstLine -> + if (". " in firstLine) { + firstLine.substringBefore(". ") + "." + } else { + firstLine + } + } + + return summaryMessage.ifBlank { "Unknown error" } + } + + private fun failureDetailedMessage(error: Throwable): String = error.message ?: "Unknown error" + + private fun clearProcessOutput() { + outputEditor.clearText() + } + + private fun appendProcessEvent(message: String) { + val timestamp = LocalTime.now().format(processEventTimestampFormat) + outputEditor.appendText("[$timestamp] $message\n") + } + + private fun setServerBusy(busy: Boolean, status: String? = null) { + runOnEdt { + serverBusy.set(busy) + status?.let { serverStatus.set(it) } + } + } + + private fun setServerRunning(running: Boolean, status: String, url: String = "") { + runOnEdt { + serverRunning.set(running) + serverStatus.set(status) + serverUrl.set(url) + } + } + + private fun updateRestartWarning() { + val restartRequired = + (serverRunning.get() && + (startedServerPort != serverPort.get() || + startedServerMode != serverMode.get() || + startedVerboseLogging != verboseLogging.get() || + startedPrintAllNetworkTraffic != printAllNetworkTraffic.get() || + startedAdvancedCommandLineOptions != advancedCommandLineOptions.get() || + startedJavaExecutableMode != javaExecutableMode.get() || + (javaExecutableMode.get() == JavaExecutableMode.PATH && + startedJavaExecutablePath != javaExecutablePath.get()))) + + runOnEdt { this.restartRequired.set(restartRequired) } + } + + private fun showAdvancedConfigDialog() { + val commandLineOptions = ValueProperty(advancedCommandLineOptions.get()) + val selectedJavaExecutableMode = ValueProperty(javaExecutableMode.get()) + val selectedJavaExecutablePath = ValueProperty(javaExecutablePath.get()) + val commandLineOptionsEditor = + AdvancedEditor( + id = "httpServerAdvancedCommandLineOptions", + context = context, + configuration = configuration, + project = project, + title = null, + editorMode = AdvancedEditor.EditorMode.INPUT, + parentDisposable = parentDisposable, + textProperty = commandLineOptions, + minimumSizeHeight = 220, + ) + .apply { component.preferredSize = Dimension(700, 260) } + + val apply = + object : DialogWrapper(project, content, true, IdeModalityType.IDE) { + + init { + title = UiToolsBundle.message("http-server.advanced-config.title") + init() + } + + override fun createCenterPanel(): JComponent = panel { + row { label(UiToolsBundle.message("http-server.advanced-config.command-line-options")) } + + row { cell(commandLineOptionsEditor.component).align(Align.FILL).resizableColumn() } + .resizableRow() + + row { cell(createCommandLineOptionsDocumentationLink()) } + .topGap(TopGap.SMALL) + .bottomGap(BottomGap.NONE) + + buttonsGroup(UiToolsBundle.message("http-server.advanced-config.java-executable")) { + row { + radioButton( + UiToolsBundle.message( + "http-server.advanced-config.java-executable.built-in-jre" + ) + ) + .bind(selectedJavaExecutableMode, JavaExecutableMode.BUILT_IN_JRE) + } + + row { + radioButton( + UiToolsBundle.message("http-server.advanced-config.java-executable.path") + ) + .bind(selectedJavaExecutableMode, JavaExecutableMode.PATH) + .gap(RightGap.SMALL) + textFieldWithBrowseButton( + FileChooserDescriptorFactory.createSingleFileDescriptor() + .withTitle( + UiToolsBundle.message( + "http-server.advanced-config.java-executable.path.title" + ) + ), + project, + ) + .bindText(selectedJavaExecutablePath) + .enabledIf( + PropertyComponentPredicate(selectedJavaExecutableMode, JavaExecutableMode.PATH) + ) + .resizableColumn() + .align(Align.FILL) + } + } + } + + override fun getDimensionServiceKey(): String = + "${HttpServer::class.java.name}.AdvancedConfigDialog" + } + .showAndGet() + + if (apply) { + advancedCommandLineOptions.set(commandLineOptions.get()) + javaExecutableMode.set(selectedJavaExecutableMode.get()) + javaExecutablePath.set(selectedJavaExecutablePath.get()) + } + } + + private fun parseAdvancedCommandLineOptions(): List = + advancedCommandLineOptions + .get() + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toList() + + private fun currentServerUrl(): String = "http://localhost:${serverPort.get()}" + + private fun showWireMockErrorDialog(title: String, message: String) { + runOnEdt { Messages.showErrorDialog(project, message, title) } + } + + private fun setButtonEnabled(downloadButton: JButton, enabled: Boolean) { + runOnEdt { downloadButton.isEnabled = enabled } + } + + private fun runOnEdt(action: () -> Unit) { + if (ApplicationManager.getApplication().isDispatchThread) { + action() + } else { + ApplicationManager.getApplication().invokeLater(action) + } + } + + override fun doDispose() { + currentWireMockProcess()?.let { process -> + runCatching { stopWireMockProcess(process) } + .onFailure { error -> log.warn("Failed to dispose running WireMock process", error) } + } + } + + // -- Inner Type ---------------------------------------------------------- // + + enum class ServerMode { + + CUSTOM_DIRECTORY, + BUILT_IN_SERVER, + } + + enum class JavaExecutableMode { + + BUILT_IN_JRE, + PATH, + } + + class Factory : DeveloperUiToolFactory { + + override fun getDeveloperUiToolPresentation() = + DeveloperUiToolPresentation( + menuTitle = UiToolsBundle.message("http-server.menu-title"), + contentTitle = UiToolsBundle.message("http-server.content-title"), + ) + + override fun getDeveloperUiToolCreator( + project: Project?, + parentDisposable: Disposable, + context: DeveloperUiToolContext, + ): ((DeveloperToolConfiguration) -> HttpServer) = { configuration -> + HttpServer( + configuration = configuration, + project = project, + context = context, + parentDisposable = parentDisposable, + ) + } + } + + // -- Companion Object ---------------------------------------------------- // + + companion object { + + private const val WIREMOCK_STANDALONE_VERSION = "3.13.2" + private const val MAVEN_CENTRAL_HTTP_REPOSITORY_URL = + "https://mvnrepository.com/artifact/org.wiremock/wiremock-standalone/$WIREMOCK_STANDALONE_VERSION" + private const val WIREMOCK_MAPPINGS_DOCUMENTATION_URL = "https://wiremock.org/docs/stubbing/" + private const val WIREMOCK_COMMAND_LINE_OPTIONS_DOCUMENTATION_URL = + "https://wiremock.org/docs/standalone/java-jar/#command-line-options" + private const val WIREMOCK_STANDALONE_DOWNLOAD_URL = + "https://repo1.maven.org/maven2/org/wiremock/wiremock-standalone/$WIREMOCK_STANDALONE_VERSION/wiremock-standalone-$WIREMOCK_STANDALONE_VERSION.jar" + private const val DEFAULT_WIREMOCK_PORT = 8089 + private const val STARTUP_TIMEOUT_MILLISECONDS = 1_500L + private const val STOP_TIMEOUT_MILLISECONDS = 5_000L + private const val MAX_STARTUP_PROCESS_OUTPUT_LENGTH = 8_000 + private const val DEFAULT_ADVANCED_COMMAND_LINE_OPTIONS = "--disable-banner" + private val processEventTimestampFormat = DateTimeFormatter.ofPattern("HH:mm:ss") + internal val httpServerToolRootPath: Path = + PathManager.getSystemDir().resolve(Paths.get("plugins", "developer-tools", "http-server")) + internal val wireMockStandaloneJarPath: Path = + httpServerToolRootPath.resolve("wiremock-standalone-$WIREMOCK_STANDALONE_VERSION.jar") + private val builtInServerRootDirectory = httpServerToolRootPath.resolve("built-in-server") + private val builtInServerMappingsDirectory = builtInServerRootDirectory.resolve("mappings") + private val builtInServerFilesDirectory = builtInServerRootDirectory.resolve("__files") + } +} diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RegularExpressionMatcher.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RegularExpressionMatcher.kt index 3083e65..664933f 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RegularExpressionMatcher.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RegularExpressionMatcher.kt @@ -43,6 +43,7 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiT import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.base.DeveloperUiToolPresentation import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.ErrorHolder +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.applyDefaultTabComponentInsets import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.regex.RegexTextField import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.regex.SelectRegexOptionsAction import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.setContextMenu @@ -152,6 +153,8 @@ class RegularExpressionMatcher( row { cell( JBTabbedPane().apply { + applyDefaultTabComponentInsets() + addTab( UiToolsBundle.message("regular-expression-matcher.matches-title"), createMatchesTableComponent(), diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt index 419593e..e161ed1 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt @@ -9,6 +9,7 @@ import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.panel import com.intellij.util.Alarm +import com.intellij.util.ui.JBUI import dev.turingcomplete.intellijdevelopertoolsplugin.common.TextStatisticUtils import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.INPUT @@ -20,6 +21,7 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEd import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEditor.EditorMode import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.SimpleTable import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.UiUtils.simpleColumnInfo +import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.applyDefaultTabComponentInsets import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.frame.instance.handling.OpenDeveloperToolContext import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.frame.instance.handling.OpenDeveloperToolHandler import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.frame.instance.handling.OpenDeveloperToolReference @@ -106,6 +108,8 @@ class TextStatistic( row { cell( JBTabbedPane().apply { + applyDefaultTabComponentInsets() + metricsTable = createMetricsTable() addTab("Metrics", ScrollPaneFactory.createScrollPane(metricsTable, false)) diff --git a/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties b/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties index e022a69..368678a 100644 --- a/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties +++ b/modules/tools/ui/src/main/resources/message/UiToolsBundle.properties @@ -8,6 +8,59 @@ ascii-art.download-additional-ascii-art-fonts=Download ASCII Art Fonts From GitH ascii-art.download-additional-ascii-art-fonts-help=Download additional ASCII font files from the xero/figlet-fonts GitHub repository.

Some font files may not work correctly and will be filtered out.

These additional font files are not part of this plugin and are subject to individual licences. ascii-art.download-additional-ascii-art-fonts-failed-title=Download Files From GitHub ascii-art.download-additional-ascii-art-fonts-failed-details=Not all files could be downloaded. See idea.log for more details. +http-server.menu-title=HTTP Server +http-server.content-title=HTTP Server +http-server.download.description=This tool uses WireMock to create an easily configurable HTTP server. The necessary WireMock dependencies are not bundled with the plugin and will be downloaded from Maven Central. +http-server.download.maven-central-link=WireMock Standalon on Maven Central +http-server.download.button=Download WireMock Dependencies +http-server.download.task-title=Downloading WireMock Dependencies +http-server.download.failed-title=Download WireMock Dependencies +http-server.download.failed=Failed to download WireMock dependencies: {0} +http-server.download.missing-start-blocked=WireMock dependencies have not been downloaded yet. +http-server.server.status.label=Status: +http-server.server.status.stopped=Stopped +http-server.server.status.starting=Starting... +http-server.server.status.running=Running +http-server.server.status.restarting=Restarting... +http-server.server.status.stopping=Stopping... +http-server.server.status.stopped.exit-code=Stopped unexpectedly (exit code {0}) +http-server.server.port.label=Port: +http-server.server.advanced-config=Advanced Config +http-server.server.verbose-logging=Enable verbose WireMock logging +http-server.server.print-all-network-traffic=Print all raw network traffic +http-server.server.start=Start +http-server.server.restart=Restart +http-server.server.stop=Stop +http-server.server.restart-required=Server needs to be restarted after config change +http-server.server.start.failed-title=Start WireMock +http-server.server.start.failed=Failed to start WireMock: {0} +http-server.server.restart.failed-title=Restart WireMock +http-server.server.restart.failed=Failed to restart WireMock: {0} +http-server.server.stop.failed-title=Stop WireMock +http-server.server.stop.failed=Failed to stop WireMock: {0} +http-server.server.stop.timeout=WireMock did not stop in time. +http-server.server.start.process-exited=WireMock exited during startup with exit code {0}. {1} +http-server.server.process-output.empty=No process output was captured. +http-server.server.auto-reload-info=WireMock automatically reloads mapping file changes while the server is running. The built-in server also regenerates mappings when the inputs below change. +http-server.tab.configuration=Config +http-server.tab.output=Output +http-server.output.clear=Clear output +http-server.configuration.mode.custom-directory=Custom directory: +http-server.configuration.mode.built-in-server=Built-in server +http-server.configuration.custom-directory.title=Select WireMock Root Directory +http-server.configuration.custom-directory.missing=Select a custom directory before starting http-server. +http-server.configuration.custom-directory.invalid=The selected custom directory is invalid. +http-server.configuration.built-in-server.directory=Open built-in server directory +http-server.configuration.mappings.documentation=WireMock stub documentation (see JSON API) +http-server.advanced-config.title=Advanced Config +http-server.advanced-config.command-line-options=Additional command line options (one option per line): +http-server.advanced-config.documentation=WireMock command line options documentation +http-server.advanced-config.java-executable=Java executable: +http-server.advanced-config.java-executable.built-in-jre=Built-in JRE +http-server.advanced-config.java-executable.path=Path: +http-server.advanced-config.java-executable.path.title=Select Java executable +http-server.advanced-config.java-executable.path.missing=Select a Java executable before starting the server. +http-server.advanced-config.java-executable.path.invalid=The selected Java executable path is invalid. server-certificates.menu-title=Server Certificates server-certificates.content-title=Server Certificates server-certificates.url=URL: @@ -174,7 +227,7 @@ cron-expression.field-constraints.special.asterix=*: Every possible cron-expression.field-constraints.description=Allowed characters:
    {0}
cron-expression.field-formats=Field formats: Single value (e.g., 5); List (e.g., 1,5,10); Range (e.g., 1-5); Step values (e.g., */5) jwt-encoder-decoder.menu-title=JSON Web Token (JWT) -jwt-encoder-decoder.content-title=JSON Web Token (JWT) Decoder/Encoder/Validator +jwt-encoder-decoder.content-title=JSON Web Token (JWT) jwt-encoder-decoder.tab.decode-encode=Decode / Encode jwt-encoder-decoder.tab.validate=Validate jwt-encoder-decoder.button.decode=\u25bc Decode @@ -191,7 +244,6 @@ jwt-encoder-decoder.validation.public-key-or-jwk=Public key or JWK: jwt-encoder-decoder.validation.jwks-url=JWKS URL: jwt-encoder-decoder.validation.fetch=Fetch jwt-encoder-decoder.validation.jwks-json=JWKS JSON: -jwt-encoder-decoder.editor.encoded=Encoded jwt-encoder-decoder.editor.header=Header jwt-encoder-decoder.editor.payload=Payload jwt-encoder-decoder.editor.jwt-input=JWT input: diff --git a/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties b/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties index a50d7c6..356ab64 100644 --- a/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties +++ b/modules/tools/ui/src/main/resources/message/UiToolsBundle_de.properties @@ -8,6 +8,59 @@ ascii-art.download-additional-ascii-art-fonts=ASCII-Art-Schriftarten von GitHub ascii-art.download-additional-ascii-art-fonts-help=Zus\u00e4tzliche ASCII-Schriftartdateien aus dem xero/figlet-fonts-GitHub-Repository herunterladen.

Einige Schriftartdateien funktionieren m\u00f6glicherweise nicht korrekt und werden herausgefiltert.

Diese zus\u00e4tzlichen Schriftartdateien sind nicht Teil dieses Plugins und unterliegen individuellen Lizenzen. ascii-art.download-additional-ascii-art-fonts-failed-title=GitHub-Dateien Herunterladen ascii-art.download-additional-ascii-art-fonts-failed-details=Nicht alle Dateien konnten heruntergeladen werden. Weitere Details finden Sie in dem idea.log. +http-server.menu-title=HTTP Server +http-server.content-title=HTTP Server +http-server.download.description=Dieses Werkzeug verwendet WireMock, um einen einfach konfigurierbaren HTTP-Server zu erstellen. Die erforderlichen WireMock-Abh\u00e4ngigkeiten sind nicht im Plugin enthalten und werden von Maven Central heruntergeladen. +http-server.download.maven-central-link=WireMock Standalon on Maven Central +http-server.download.button=WireMock-Abh\u00e4ngigkeiten herunterladen +http-server.download.task-title=WireMock-Abh\u00e4ngigkeiten werden heruntergeladen +http-server.download.failed-title=WireMock-Abh\u00e4ngigkeiten herunterladen +http-server.download.failed=Fehler beim Herunterladen der WireMock-Abh\u00e4ngigkeiten: {0} +http-server.download.missing-start-blocked=WireMock-Abh\u00e4ngigkeiten wurden noch nicht heruntergeladen. +http-server.server.status.label=Status: +http-server.server.status.stopped=Gestoppt +http-server.server.status.starting=Startet... +http-server.server.status.running=L\u00e4uft +http-server.server.status.restarting=Wird neu gestartet... +http-server.server.status.stopping=Wird gestoppt... +http-server.server.status.stopped.exit-code=Unerwartet gestoppt (Exit-Code {0}) +http-server.server.port.label=Port: +http-server.server.advanced-config=Erweiterte Konfiguration +http-server.server.verbose-logging=Ausführliche WireMock-Protokollierung aktivieren +http-server.server.print-all-network-traffic=Gesamten rohen Netzwerkverkehr ausgeben +http-server.server.start=Starten +http-server.server.restart=Neu starten +http-server.server.stop=Stoppen +http-server.server.restart-required=Der Server muss nach einer Konfigurations\u00e4nderung neu gestartet werden +http-server.server.start.failed-title=WireMock starten +http-server.server.start.failed=Fehler beim Starten von WireMock: {0} +http-server.server.restart.failed-title=WireMock neu starten +http-server.server.restart.failed=Fehler beim Neustarten von WireMock: {0} +http-server.server.stop.failed-title=WireMock stoppen +http-server.server.stop.failed=Fehler beim Stoppen von WireMock: {0} +http-server.server.stop.timeout=WireMock konnte nicht rechtzeitig gestoppt werden. +http-server.server.start.process-exited=WireMock wurde beim Start mit Exit-Code {0} beendet. {1} +http-server.server.process-output.empty=Es wurde keine Prozessausgabe erfasst. +http-server.server.auto-reload-info=WireMock l\u00e4dt \u00c4nderungen an Mapping-Dateien automatisch neu, w\u00e4hrend der Server l\u00e4uft. Der integrierte Server erzeugt Mappings zus\u00e4tzlich neu, wenn sich die Eingaben unten \u00e4ndern. +http-server.tab.configuration=Konfiguration +http-server.tab.output=Ausgabe +http-server.output.clear=Ausgabe löschen +http-server.configuration.mode.custom-directory=Benutzerdefiniertes Verzeichnis: +http-server.configuration.mode.built-in-server=Integrierter Server +http-server.configuration.custom-directory.title=WireMock-Root-Verzeichnis ausw\u00e4hlen +http-server.configuration.custom-directory.missing=W\u00e4hlen Sie vor dem Starten von WireMock ein benutzerdefiniertes Verzeichnis aus. +http-server.configuration.custom-directory.invalid=Das ausgew\u00e4hlte benutzerdefinierte Verzeichnis ist ung\u00fcltig. +http-server.configuration.built-in-server.directory=Verzeichnis des integrierten Servers \u00f6ffnen +http-server.configuration.mappings.documentation=WireMock-Dokumentation zu Stubs (siehe JSON API) +http-server.advanced-config.title=Erweiterte Konfiguration +http-server.advanced-config.command-line-options=Zus\u00e4tzliche Kommandozeilenoptionen (eine Option pro Zeile): +http-server.advanced-config.documentation=WireMock-Dokumentation der Kommandozeilenoptionen +http-server.advanced-config.java-executable=Java-Executable: +http-server.advanced-config.java-executable.built-in-jre=Integrierte JRE +http-server.advanced-config.java-executable.path=Pfad: +http-server.advanced-config.java-executable.path.title=Java-Executable ausw\u00e4hlen +http-server.advanced-config.java-executable.path.missing=W\u00e4hlen Sie vor dem Starten des Servers ein Java-Executable aus. +http-server.advanced-config.java-executable.path.invalid=Der ausgew\u00e4hlte Pfad zum Java-Executable ist ung\u00fcltig. server-certificates.menu-title=Server-Zertifikate server-certificates.content-title=Server-Zertifikate server-certificates.url=URL: @@ -94,7 +147,7 @@ escaper-unescaper.to-target-title=Escapen escaper-unescaper.to-source-title=Unescapen escape-sequences-escaper-unescaper.title=Escape-Sequenzen escape-sequences-escaper-unescaper.content-title=Escape-Sequenzen Escaper/Unescaper -escape-sequences-escaper-unescaper.context-help=Escapes oder unescapes Zeilenumbrche, Tabulatoren, Rckwrtsschrgstriche, einfache Anfhrungszeichen und doppelte Anfhrungszeichen. +escape-sequences-escaper-unescaper.context-help=Escapes oder unescapes Zeilenumbrüche, Tabulatoren, Rückwärtsschrägstriche, einfache Anführungszeichen und doppelte Anführungszeichen. escape-sequences-escaper-unescaper.do-line-break-decoding=Zeilenumbr\u00fcche: escape-sequences-escaper-unescaper.do-tab-decoding=Tabulatoren (\\t) escape-sequences-escaper-unescaper.do-backslash-decoding=Backslashes (\\\) @@ -139,9 +192,9 @@ color-picker.title=Farbw\u00e4hler color-picker.content-title=Farbw\u00e4hler cron-expression.menu-title=Cron Expression cron-expression.content-title=Cron Expression -cron-expression.cron-next-execution=Nchste Ausfhrung: -cron-expression.cron-last-execution=Letzte Ausfhrung: -cron-expression.cron-explanation=Erklrung: +cron-expression.cron-next-execution=Nächste Ausführung: +cron-expression.cron-last-execution=Letzte Ausführung: +cron-expression.cron-explanation=Erklärung: cron-expression.unknown=Unbekannt cron-expression.cron-type=Typ: cron-expression.cron-fields.input-second=Sekunde: @@ -167,14 +220,14 @@ cron-expression.field-constraints.named-month-values=Benannte Monate von J cron-expression.field-constraints.special.last-weekday=LW: Letzter Werktag des Monats cron-expression.field-constraints.special.last-day-of-month=L: Letzter Tag des Monats cron-expression.field-constraints.special.last-occurrence-of-a-weekday=nL: Letztes Vorkommen des Wochentags n (z.B. 5L = letzter Freitag) -cron-expression.field-constraints.special.nearest-weekday=W: Nchstgelegener Werktag zum angegebenen Tag +cron-expression.field-constraints.special.nearest-weekday=W: Nächstgelegener Werktag zum angegebenen Tag cron-expression.field-constraints.special.weekday-month=n#m: m-te Vorkommen des Wochentags n im Monat (z.B. 2#3 = 3. Montag des Monats) cron-expression.field-constraints.special.no-specific-value=?: Kein bestimmter Wert -cron-expression.field-constraints.special.asterix=*: Jeden mglichen Wert. Z.B. jede Minute, jede Stunde usw. +cron-expression.field-constraints.special.asterix=*: Jeden möglichen Wert. Z.B. jede Minute, jede Stunde usw. cron-expression.field-constraints.description=Erlaubte Zeichen:
    {0}
cron-expression.field-formats=Feldformate: einzelner Wert (z.B. 5); Liste (z.B. 1,5,10); Bereich (z.B. 1-5); Schrittweite (z.B. */5) jwt-encoder-decoder.menu-title=JSON Web Token (JWT) -jwt-encoder-decoder.content-title=JSON Web Token (JWT) Dekodierer/Kodierer/Validator +jwt-encoder-decoder.content-title=JSON Web Token (JWT) jwt-encoder-decoder.tab.decode-encode=Dekodieren / Kodieren jwt-encoder-decoder.tab.validate=Validieren jwt-encoder-decoder.button.decode=\u25bc Dekodieren @@ -191,7 +244,6 @@ jwt-encoder-decoder.validation.public-key-or-jwk=\u00d6ffentlicher Schl\u00fcsse jwt-encoder-decoder.validation.jwks-url=JWKS-URL: jwt-encoder-decoder.validation.fetch=Abrufen jwt-encoder-decoder.validation.jwks-json=JWKS-JSON: -jwt-encoder-decoder.editor.encoded=Kodiert jwt-encoder-decoder.editor.header=Header jwt-encoder-decoder.editor.payload=Payload jwt-encoder-decoder.editor.jwt-input=JWT-Eingabe: diff --git a/modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistryTest.kt b/modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistryTest.kt new file mode 100644 index 0000000..d794769 --- /dev/null +++ b/modules/tools/ui/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/ExternalSystemProcessRegistryTest.kt @@ -0,0 +1,84 @@ +package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.other + +import com.intellij.execution.process.ProcessHandler +import dev.turingcomplete.intellijdevelopertoolsplugin.common.testfixtures.IdeaTest +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ExternalSystemProcessRegistryTest : IdeaTest() { + // -- Properties ---------------------------------------------------------- // + // -- Initialization ------------------------------------------------------ // + // -- Exposed Methods ----------------------------------------------------- // + + @Test + fun `stops project processes when a project is disposed`() { + val registry = ExternalSystemProcessRegistry() + val projectProcess = TestProcess() + val applicationProcess = TestProcess() + + registry.register(fixture.project, projectProcess) + registry.register(null, applicationProcess) + + registry.stopProcesses(fixture.project) + + assertThat(projectProcess.destroyProcessCalls).isEqualTo(1) + assertThat(projectProcess.isProcessTerminated).isTrue() + assertThat(applicationProcess.destroyProcessCalls).isZero() + assertThat(applicationProcess.isProcessTerminated).isFalse() + } + + @Test + fun `does not stop unregistered processes on shutdown`() { + val registry = ExternalSystemProcessRegistry() + val process = TestProcess() + + registry.register(fixture.project, process) + registry.unregister(process) + registry.dispose() + + assertThat(process.destroyProcessCalls).isZero() + assertThat(process.isProcessTerminated).isFalse() + } + + @Test + fun `stops remaining processes on application shutdown`() { + val registry = ExternalSystemProcessRegistry() + val process = TestProcess() + + registry.register(null, process) + registry.dispose() + + assertThat(process.destroyProcessCalls).isEqualTo(1) + assertThat(process.isProcessTerminated).isTrue() + } + + // -- Private Methods ----------------------------------------------------- // + // -- Inner Type ---------------------------------------------------------- // + + private class TestProcess : ProcessHandler() { + + var destroyProcessCalls = 0 + private set + + init { + startNotify() + } + + override fun destroyProcessImpl() { + destroyProcessCalls++ + notifyProcessTerminated(0) + } + + override fun detachProcessImpl() { + notifyProcessDetached() + } + + override fun detachIsDefault(): Boolean = false + + override fun getProcessInput(): OutputStream = ByteArrayOutputStream() + } + + // -- Companion Object ---------------------------------------------------- // +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f811f45..b7bfcde 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -304,6 +304,9 @@ + @@ -336,6 +339,12 @@ id="MathContextUnitConverter-RoundingMode" type="dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.converter.unitconverter.MathContextUnitConverter$RoundingMode" legacyId="dev.turingcomplete.intellijdevelopertoolsplugin._internal.tool.ui.converter.unitconverter.MathContextUnitConverter$RoundingMode"/> + + Date: Fri, 1 May 2026 08:32:28 +0200 Subject: [PATCH 04/11] chore: Update Gradle --- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 4 ++- gradlew | 14 ++++------ gradlew.bat | 32 +++++++---------------- 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch delta 40229 zcmXVXQ(&EK({&nS$F^-dX>7BxZF`41Y_y}swi~mtZL>jRJN^5--+yu+T}SuKtTnS{ zP46P)^ebe&JqnO{Y6>xo4GntNpX@3TcXjqLL%&=dI#nE4x6x)tHk=s#_)yinVtSMX z+kHWA*`}fB^(cS-JN~B~Vm!?){vAgDl9h~nHicLGAf*{R7Jp}UEbZcQq6)ND>!PDG zJwpLhX3}oGIMX>xfXTj~7v9xk`lq}%HU1O5k7bd{^B4-eTHbys_v3S9-cq(N&d!YZ6Hu0w_=ovW9WKE$6j~oBo@}U7p5D#3SYMW z?G(}RCU4tfwrf!tR|kL+UkY6IRm<^o4Oofbaq!NEA%^{q@bSgJUaB5ZT8?3fr690e z4U$X6&$<5Y^`Km%M(l&|zu$2HcP0xKLemCn=&N9=p?#t_ep_cr$BEg+UO{rSt=Mc* z-$%L;S0B`c?0a@w6?Q;%@Xp#|p2K?~eT56qqQu~07kLwW$LxvviIj?AG(Gv_!>WgW zXY%v)?E)_N8xvdC&W68HP?4ispv)?Wr|wi=Pi!GaT0?I$J>JlP50u}=$kZn+MtTO^ zWc=elB}Rb^T+Ef%!8wEY;&PGzS&u{1rG}Cbd44|sI+3sy;5RqePWc@a!-n8+7i~lAl#d* zZysggZqM+VB>z<`6K}?|#Dp#cFwfwJmOu$Y0tA*7+sAWZ#c@dL@a76@x*`C__*nKE zVDZ{d^+5iJ<1~Nqov^Oy^S#7C<$eT_&{C9?8 zX^SN#J`6@Ldq0iJN6jgsi4YgFi9wO;&4NebskCZ{F5c6M7rII?3!2{vVI^q%MR-kq z3>lEk8tP$cJTqQ=v7YWa@E8QnN$E*}wlh^WdM2}~Lf~bSL=Znv)NW<1Z*|E`Y_#r^ zm0&oMFA8NvVNbHlHc&dy$=`e^dT(wa!gi9@_#o9-_YFekPr|XQ2#5|S2}92GA0#}FvZh682*_>JF#08M+%HE3@xW9 zi^Eok_iT2#-dS7SZRjMJg7-rH2`g$7nryR=dM$&m(u$6j#?&$=+U{PdJZ z+6y5h8M3#qw>NPMS2SJ&N^EIx(B(GotB348^%HV~vV9F zd7lO9*1ED{AxST=2X1q1@kOGck}%(Jm}Tp(Kd^=Z*9p4%~F z*!{#uLgl8y$9Yv3uwvX#nq?bIY*8i$NdE>Jgz{{PfUP1-Pof#OPUr^=YDI;5rxGV? zj$1irljaIcslrT))$s=D1~O()D?<;<%kM}5;P!Enonz_*g|a5>Az}ToD~>qmThdJ@ zR0Gr_-)LO@c{!g__WOi{w??B05MX-@$pRS+C`@nZ>Hfp|P(7-wQ0A$)hnQnd#tVe> zozJrF_In1N;#<8UEW3n}Gj7Yo)G?I(M#7Kq2+EnS8GVqilD`RyA(Ups-|WA@axw#% z7Q%fE>r32i33P(ssq*iSv4P;o7|M?CY?Fa%mTrwR<-%#0yR8!M-q%tw$p*<6bxw~K z;Zw58^~7`4@V{nCLqb_BMC1&zH<}GOR<)26f;%Fc4;Z-SD7L~Co|DY zlA|@l1Qbw~n^$uda9(UWu#AoOSa|WyD$9IE23?}ob7~XWE;&y>7cKtmbUjo`W2O0- zb?%<$e(5{S|8$A|Vh1h=aDS zK|mAL^n*>T=)k#pRY=BP)yNLQ0SL7C9mH7_9v^g0 zprwZ2ZvkP)icTwT)S|MEH)I?ngzAjwoo-(d%iuR?f$$ZOi;+E>jLlOCdShr#x6C@Ywn)YezG3R6Ty0>Jwec8<~6>wH=JLU zoAHTA$tgtfE>SYawAslhUy+dPb8g{&>8Zn~S>48TDe)c&l)e^Q=p0!i)_{Y9+0*LP z>7*+N^Ht`P{KB_B3(F$$q7aBOk2h$F9rJ*;^R-i-w$fQyn!qE7Bd;aL+g?dw^>azo zV@`e#OXBL2LzZubZE;+#vBZdTOpN1CjD_8xf|7@sU&2jf57X>TF}kU1<=Z*=El{`v zTaO9Goo+m1ND8D8=@Tt!oR&0_FoG}4E26gR3+g?v9?dQC*7T*=wgxiX6vn|An94HE z|0+9`w0{*KZtCp9n7qvW^=!3J3C}AzD|4qC0M0e_T zWb9$xxN-6V*H?zx?IBhDcQyq2<1=l$@9T@zFM$u}Efp7^L5fOH)r$~=^UhRBm0 zu?|HQ%pW@o8yNvzVO9bBH=-;l&Q10upFi$74c3QMSiS4f8q=B`Kt4L8k#mM+{uqu| zY{X9VbEEx&+0x>OC4fkXv=ahq;rmw#W=B3bhSxEZC|UX#ubA?MrVe=`90mu8H~;@C z&Ov5(UL6Vo0u1**gwLg<2506`19P;`jER=;gJ5xB)8r@)Vdc28rjXMN*>JH#N+G4c zGbO@8Ze>&sTz;XMymYX#BDYcU>n-nIV_aS~Dy-mkEHWfZGS(M;kmzmw`}M5>7+jp+>J)nQJg}MrL;i89LE3ql*iPE=)ijXGg+#*0n2czaQ7LlP& zTev|9mX{p4BM5zaSIv zj2Ws9SUDthLRBj=E?Fj6CsX`pIB}YknqAa5sChx-a^xZoc`z$2OnN9ku0fe9{&Bxf zF(6!e$wy2eZc{6b*$+hIsUt$smll^o3gf~O3{)TZNUp$^!P@*u&=F%k**!+$N!z8G zGk*<1a_?>-Gzb(}h8{CR7btfQ^?gZFI;*$9#@PGc(GJuGP(9VtKKNpPFhyr(-kmxqz$xR{1UBnK#-DFewI^);l$sUXeMh)2DR z2a7Mi*1AGPq6$RnSGpv#lPjoO_mHwAU6sj0TQH}CMsC964lA_SnKZxaq;&^*P*(2h z&JbTiW2Cu2(oZ0_y%xitR9NOkHRVO8=ag?a^|l>yNF!{cctu?~L$fs#%S50l}gEI5R9yX(H zA)Fnt=JUd{!LJk_GtDcPp>xy7lq6X=VG&P25R3JwTPmk{`wA9*Rtl(s-TBqZ z4<Z1mvNPz*?Z1Dda>rdk#mYXJ$f)hB=-8xCHql$%uN_%U&@~4!I&3v( z><~u~+*N0fFPz>~JvW5k5@eZPw8!LeU3*&!v{J_~EGKyPvP-qvvKsjH_Bns}VD*}xJ0>-i z;!S%W8s774KQMev#(;`Av~)+v0Uvi&1qcU}a3p1`+{mS!CyFqTX6su7K3FFg^*_>; z{{}`B-(DE>5tAZrE{$7*A&{9U#yl)@wsLAWN2xGTuN%r-KpFBt+j6I1@vUje#LiORoVFsciFAFaRV5+d>U#U<}!y;NQ8iDlRwF}R8 z;4g>$H&^;(pyM!SG*cF2NL_P=qlIcs=do(xVqGtGXS(n3G9y}|_H=E&G!T*_IIclQ zK<0iD-z(^;`quwA6n@`0mUUsCniA$Fm!&bik5Hp7RfqUaoz113Q-552p&iEOZ z32Q7dBs46OX}%}xpcCdyGS(MBCBaT(0ZiHdW^i8Q0d&V%*z@hW7OCAFOn?6V5Fz`f zM-`jyv?pkZcf%G?#~FL&zV=al#Q@a1B|8gxreUZvb&lSg!f{J?-=m#7(tnN!{l*pE z?yu4J#<#7KivEpoCG9h5;dxo8mepNa0%n?qVrz$#<_fvcH(D`pk7!}um~t<)!;92Z z!5JvpdGFnym;MIB@Ru-^)L`ryqB4Cp7O@2Jfg!$%O>^5&*G!7uh%q4QCn?aB7t_sB zP8-LT!RVOunsxJu^!eop9mR+17%IHGtCX(3gCIfmo8Zq^KKg<4DFm;wF~qjf%gL|G zXG%Ulvg_&vt=F>Z8Ys3YV$RN|m|FJoR^w51BT3C@MWlt#8pYOT()+&6Xty7$z5u`c zN5fzJhI@_RARq)#As|#0puo%6LSU49Dli>60GLX_CN)Ug6zYX)iCB(=_Z>q?5iO1r z&)0ip+cWji%T^G>{4nepKcFCbdJs1VLNw*3$paP{K|JezGxu}mDm&Nza>jTE2%!ra zyEb*ck~+xQ-d~PSZ0RY~ckC|J583e)Cg}{}`uK%&h#EobLv>;vDO0+HzO1}>M;TZ$ z1?-kxystHX?XvGckcb-EQ0Nj{uV;^PeiBL`Qj5%L#fbJWpqD(6kL>ECh@-=OAw*I@ zLSDe>e0_t&3tI>H6;S84<(jl%hAVF&v|mJb{BD0}{`J(}v=V)v9hn4woZGG@IXdN} z{yH#gJ1m5Q2*S6M8NSS7qL@iURYx4S1->RVydj{XY14n^GHyMK3>Y3z1>fQf)Za99 zm54=@iuYxJAY-KBOtMY;kDc{Hi%q+JEb&`b_wIFeUAmqwX1UI9EZy>inF&RWABi^y zFJL{@N%mpM1&}P{N)wc-sk`DF;|)W;;0C>+y^|xTELRU5$k4aR-l5ATdkLd200TC; zJ}KY&x(HazwTGw;=3Gc{lWEQXMIoE;hr6w$#LG(uC2|wRw&yS6!vwYFB4fT`Jd`y- zXfL|!0Dj7?XKn#*z(KTs(Q&Kfb8@Rth}?QW#57A0_7r4|v`vj!O0Je)mNbflg$!ez z`;JTHD>i0ck^$Hwonwwk4kJljkvZqo{K$N~YK~JSt;E4jnkJXrEdE^gHxkWjn_kU> z>xIT}_CNql*1uj>Qu|^6PjkO~Uu<4RvO4aD@1#~ju{e_w8peo^7zi{xcjmx)yJ*Ipsf)=gKw)@G_c6b72; zZF0uOTjAb?y3jNA8Hx=qs%z8m#>wWhJXRXH$HCi{v|q4~Oz1|U3_l_xa7ic->DY%dZHmYz z!;r=d?WqVWm^vEa7mJXun`Z;np8O#ZJ(P0o!H+75p^^adB^ zr_=E~*^q3qk>m@8i`;7f&tjBIPYj}aI~2OChL(=SrU=TldPBc}{P#z}Ld@TjnTcjf z`WXroa&4&>)Nc>879NiJRd2YWXz?sg+QRa8@g;(f?|21%rfpz$$mAeU#^}P&um=eK z)K8_8Rn2HVKN{Dwq6G(ac7c_gomlFu@%*ah!To45n(4;~r>ZePN?F(f(|f%GMtnLv zYa5R6Rtwkk%JZn<%0{LzDz@kfG@9#Bv8uK6dlso0qKqoCAvO1r8?VS|*HGXnl-)RhwCjuMD5CO?|a_Du6&uk0~2+F@WuRX=RL5rB;M|&3+&-i*! zxHc-;1$Tt+w4p549>yG`U~S?W`L6C`BGlg5G}Dlt?@h1H&52*jhNS%+Hv7ljqjmxKQZ442P09NSv*ueN?uJLeG#g4 z?9$?3`OI)_yIfrg2>&-!qlD!7%X@i@E4-aEJn8%J)s)c8WZgzq9>g_4F2Zw==L3Ha zfF>gwtQ^)&_5-$4O#QG#^TeWHpi4H|S0ZK>BbKwtl%wcyQ@b4*_*h!bbOCFLbEFHJ z_eVqC2CyF)VE(Pi)0Ss9wZ`kYr56Baj>_h+3(}guupS+J){{okPlY*GkwG_&NLgaM z(qSXuVbR2LgjE#Msc{I<-4b~Un3Z92rA^))%s;&!vwNMC!+3ttx8@s>EtoDm7{jNo zg;Dx4)vB)Wr2Mr8xFKj5spT^Z!fAq8?2PPQ;Nc3`r006W^OnTD>>h*eI)9Y@>u_KM z^(G`y(B3pIC-{Z5qo~tqc$(GZZ!fVRe!Ggzm4R<~U2H;IqvoN{w+ASru3#l+H_hLe z9L6q%Q7d!1>>swF*BW=-Q+1JXp$#|t1j|7_ffEyF3ciu9td{i7KHq*Vl)#OguZJk< zGG*lmiG+?I1?>AySKf3(Ht+lwx6TDW%z`fv5P^{YUuu*9I%9}o32P*lw8@5OH)&OE z*o8Er)zB1ki>wZo*?rF-2w)%JVA{CUvBEWq`}j`}Un(w%rul>brucam+(1oIfjO-& zg`cK>ynT$MJ40L+O<(UlKTF-!Ebk2`xpc0#Slu^eTKU8~uL(5?>3Db5k{KcDUw@so z%!BKg!yN{yvAIAxc8*+pUF6zXB)&Y;E+!xBhSTqoYiRV%-634%K^MzA^DaVLX)+)$ z{+&695&U$uM0kl7FMg(N4^VIjp>R#COXo8(mRWdj|~CYXUKB$mHN{ zv{e#WQ>5EINhXXN)@ys{Q>OEC^7;C6(AmaG)iP(7L{C**PYEu*P1bx!CZTDJTIYO? zDIQ@&kE9OzU+eUxgOGsyU%^}bpGNJ<_!XEmqfE$-Harqe#g&MW|0{Z;GO~EQaX+sq zv=R<2l|d?4b|_@y{L;ewdOgV-6G7{@m``jj!^1DAYgOz6G`&QMhIzl?yTH#i64Cb$ zcT|YZdY0YG$48;}Jt;h6uyR)*>!q(FE6>_j2BzY<0j@#{*D!(M=3kvmZEq=q^w<7=1f&=8sh_p=nevGaycy@@1`!%o5Bh z$eSH~Xm)?69k~#H&-2XPXnKz{ z(V*OhDf8|CiDS5Dks)Q#YhE0suXm)~UI*Fl89$Xz~^kS$v*0TJ;^k) z6akg(^G{B1tLYz{?oXeecXznKof($TjlF*x-{tDu>~)}9dh+%4{nT2;dyo8tyas+< zOX#Z_AgWx;s=8&#fSQ@Bx5_VNUi*PKPZVm4_VIgnnR^T<KpL|VpqE2DblM)x|!nVi)NUN7q?u%CSCe4$ilTB+W-vI`^5)A4r5*5JtqCb{e7EzI(Y-MX(vbMViGQe8 zJfU0aJ;;_XS-tpCnONtuggH9TFn^zkc{pd7n6CA-*yN#Lp+Po;mk)G~R@U%PP9|n? z=u&$BwLAF*6bpyPjl%XxY_rd@=T_B`$3(f2`o2DH*eA)D?)Y0hLc-u}Szcw&Pru{L z!|#^#RfJyqq@jOU8mef9u(&*A;|j&J%htr;3*KaDIP`Z3Fy@9TW{PTP;xp2JnP+v{ z?dbLoJw~AaZ}5tw#049nk$}naV8QWh=s-gqOc}hOVQAh#UFvc!9Lp?ugpfoAJ<4Zn zN^L|cSyjThc~fs$ZJth7?*W+_qx<`gW1ouj?wghx90%d%?n@=0Bv3|SXX|$rn$Q&g zT8{7W59@8;?SzkmJmYN$zVA{X>jY923zA{$HV#?Zd`glyjH1vX(D%Njs5vw!p#+XLvKm*_-uQhcawu zzXq*gl-e?YoN0;<>}jOSlOy%0Xbd!_DT#S`E<(eTXtP;nmNHoI=p)P0<~M9l5w|q)#zV>Ap7FD+IBDZ0 zJIBdA+pLYd$(;D*xnqEAR&$<0h~ZVL8vh$ z5?6pmKX;bKjAsj`6aI*$V`<7+NG>e+fG*LxQ18#oO{2Td}J5 z*NLV)8H_Z|{;C5_&eb{X0R#*6v;nL!HNLNU&3rL7oe#`2BS2XJ_)9Xe6K(%V$wLpT z#h|}$-!G*vtw2-$q;-8?pt{$9T}NppdTTk-Y;mpWlr*rucFjQi!xXLd8$~N}XtozS ztIDN{j$|>SAPP1)?j=h$*&WY?8s8!Rg+R7H;=B1dJWKnR?x+dC4RGoJnkldgIZ8{U@WyGa6#6SbNumKahk2_#r-Y*b$u6z82D~?DS z5BBr;yFnbTvTsQLDezSZC4t=otHoj72JD1T%Q9A(QPQQ3pqX#ylo*fU)YZ(4y{ySv zYn_feU2S!fWxU>5@y1L=M2Fh=N)>1SEt5?F3)S2TdsN0oN^lr^t44CkkYoS7mCr~PoQ>pE zR<36z@zc>)F5ovkv3qWmRB3G&z8B2(px`&8y)9%1xJRGWWt#<#c(~r6FZ=|+h(RQ( zz-!F={1v#Lc>If!EvdP@xRAIB&A>=ju<(d7GPH3aGmgQq%!~^qPF${@^b!8ND8(tk z3@hau*3=I4>fMs4F{KXTCt?K`Mifl!T)`nDpw8sJ73lc*&hmJp^kz}PwS0%@t?hC} z{me05BrGan?GsZZN3et!G;%7$pZ3QPZ$GCe3eCE%lGBc6*DUE4fb@Id_$&m-U zzOFq762{1+QiQ0ok@}TogF#I@B`B-wW6m@ys_3wj%%viP3`OF-wsjOUYu*tl|`jOerPwSZx z_wBoEu<_^XrwoLSnu*Wj{b~1edLJ|O&xr>I7n+PQ7|OmlHM+`7vnn8ngc5Bwm7B|eE?Y9CrYrh;F-`fxcBG85=;0rgmR7bmgplKkI4nLhcX#ig<4WAOVO(;n=sZrLX z1OH^c86X`E2^WV?1hV2ZP+{KLpp@EC3S#25P=p-4RQ}0~R6P-T&ImpR{LELCscrr8 zd9tJL7I#8UGL|ty6H&Eql40+U`ZvLK5eHZ8JKj2o+L8Z;AFD5#8Dt{ST7Ks%Rkbs4 zdjP0I&jh}5sVZUP;kQUbc9_C73duk;9*`VDw{PTo<4m;x{sDz+v=_I8^LS}5lpg03 z4C`)@lk7ckk0yE#a$ckmj%yIh*+#^^-(w*Z=`%jH8?a8fUto>R%pV}qPi2Wr zqT(%9P!s2~>}yrWiIqL2)jvJ%sYTYj5%2ai0<@8u#6tv5h>+&E(MKokJLZ=1=J3~9 z!^*ZOCMy}UZ;vGof#ErM@nTP*TD!E)xR9rGLolAd9(ex)s@flBBccC*N*x}2!>V2ykwd9bxGaAeVZKj2A=dn;4?HDSX11+X zrzo1wSYyYZ=*&Px$%G5Rom&lLk$~5{{MjGwQNx9BZbkEGPD!a0RGtcC{9baxpn&m1 znt9qjp;X0-jkR7R(fri@$8b=mU_P67JzYItS_c_p{Z{6wRBaQ`HZH({J}b|(Vb7q1 zp%;zr;zCqYt$3*#rN-6mgvd0$jBKE?u9ZPw;R z{viBnx%YO%ZFKYWl;HB2^QsSw)g{IXf6D(&*yW4n1RDROnQE{Y8Zr2l4izjUiURI0 zq6B(sO?YE!;r-nzWn18iz}Qwdg@?4wjOurK#IYeGO1@hy^}bupes^yO_zHw;(-2 z`VA9?ous`slpWgLYie!?VE_w&@UQ2VM`6vNu++_UVR9;sDVERKH+qR1phn?cNCsxR zU^Q`i7#|%7S-MrF2=M!TJIM_#3C|oeB$@^g3-V{1n;hedv#=r}a11hH+30h<4YD4~ zrNQ~-VGG0jQ&8dA=|KKn>JK#dkaOF2@DCN3C`F~n|yAHVF z^DqH2S=?!9YZt>yVxch1k(cG;Jb{Nl)NhEo`ySd3+6O>7R=Qg;Y`zb>zNgJeUDBF1 z_o_RS^%@*Sh!gEmr#?l%II}kc zmR7$dkEy$uwh|_#0_E$-lqw4Q@Gx|`(c;a0Y2){xBN}CY)ieB?JEo+mCUugMyz0=Q zW}HfG8!iIUe&VIvM4s?>sRDKXEfyue-Dvno^!#ZOigM8BsMu%vo#ht|%N=f7an+xt zX)~{SR>k!cgd4#&f1cKg$0OV~ol#2X%6aUnLeyIsTe*pd$ih)=sCZ{H#fIc|%{S2T zb2-COMK_b_JI{_eR3@Tulv*#GPqTa&#ldON0pO&lC(L6#S#Y-ier@I$lIq{5I>#CDFNR$2Mp5TXvu$tP}i6Zd&bBei6 z#DNmX1vy8oE6n@2`U40%xh|0I9uUS5nCA73uTxh>b9>;8xGfqLe(vYb$8eYVrm;MS z_K0QXom+H~H0PIaT@^g-zFZfr0Qsq$(j{f2a6Ji9!owxb08OXU-{02HC7Q>}T>=ms z_o!7*P$Nyd4TA5Nq^;3%s9v%MTN|*xB!3cDnGa!owk4!`qs0ItbXFRI5POIE3ID8e zMdX?7O?{3O;X90Nk37B?Tqbqu;+!czEY9)zpfdV(Xn4CaE(`A?z24r~7XMjIotZXK z397#$x3N??xRxNY9G!o_1_|ntmesA`2MzW_@{Os=I6WI3xOT;J-thTc}M&+rY#XH2+u+}RB>K;;AF2e(|qQU26BpTa2t zJZKXU7d>z;WqZQ6;9Hm+$`jo?R5OOcYB7FPx|2d@fe=`pS8tdRtjx<7K`ZAA8+*Hu z-uhOtHRRD^uoZDeLNM!TYtp&xtF3eE9138Ui$6$;`jszY{5r#f!4A(k4VGXH4N^Oz zPUP3a+jD)q{@zSE%Z}M^-eBX!;2Gg-(*NkH9j_OX3Qj_C z4xc1v9~;G$y}`ZN3YA#iyCRF5xM(jb&nY;{?zXvnOXlb2PO;HX`Z-U-a z%92{qZPWrYlS*-=Qj&__4q+HFh18xBom3ql+)kziql>K@SP87D%|-C=@|ua%l>i1q zK!vEW)X{jDA*wjrDW$e!tZw@*Wu8#jBJi_&boo#3|J{Y~hW4wJi(Hii5) zj+8X6xdk(aTq4xTlL=Kl^D0}u)Ux-%3+I;dGonqo5M8@r zHD#x?fUVE@-C~K!HCf-_SUzWAuTfp4a>Ah7&{^|ruA#AT#g{^_imFPUxdMSSJ5R)0 z8lT$G-eaL>bJi1MF+KH=zQ9bAE6iPilYy#xqDH_pDZt}!a@3}A@JgE0rw{Ml_orz^ zfb;^t1aPy7p(3Uv#Us~lH8Xb}kfjeQEUVR*uNkhPk4tI(IW(wiFS56wp*K*qbWh%3>&>evmZ&Sy;{e>CB zJ3IMe!)GKCd}4jlGF9!Hz#!2*z=pR`w}mbg^9212A+fW)Fa|13Z=*I%oVVc~h{9zD z0k=^AEr{fA?BRS%>Ri!kX0l%DTeNnRYJ75W@VN9K%mx&P3r!(vEEP^4$Df{~E_}=E zBR~0QJwz?|K1H=s+2n~8Le3aB-Y-s03|(8LzQpZ`^NRHI{jSagRj&Fw7-w;=Des#1 zeb8D=TydQ+Toia=dM71UyZFoN{3*s*1Ev;JQW@u&=^2FLgJ!+rnc+MnL^s^4)^i0- zVc4Q)K4d8+%DF<)%519nbeNTD=%5Qzojur;;1UP0O}GzxMBdeJX-1D)>6&Zsq0^js+K)+Yqbem)E8fx#3&`jPrvhf)QYd6q9AfL zekTuT&9T*VXRrm;onNfiOm`r66Sk^SQ|M9iWwf5|i76jRX##}55uCe!w@cEGhbGVT z!4P)tu#qZC)Fx+#Kiz>MK!cyH1Il$CxXclrBq2p`-w)=Ir}%(IRnS;TQHok?Y?2F9 zvr|{ta}&cT`$}ly|HgS|`H0?ONWl2g3w441nHZpDGWjseq%e<-cg=8(3hENSMamc{ zg-4$5&X;eFo$waQ#6b1>AMl^t=R?xw`$%Xc+_#$ zuQSSKag)MK;6(j%t9>){fUf}yPD>T7qps-lqM;)N$l=7wkta|Ns$EV9xy!j3IE7RO z7|$*8F6mz|%L23BdMnZ|6aTXD$IO3=zm6$(p3CLGn1fjX{zqeo!d@TwtN+HXKk)xs z)iQ}G0+SV-7rtUe%*-~iROll*j=x#*&<}E}CRW2XxS=AH z1j6?gR%sSu=u-t@x#XyRg+Wkg;T}2ee9FBE_;@_!43@cu79k-z55MY;k9(xQ@FO%@ zydyA5p>Cg_uVi`vS1VK#&Ql@nCj@?bR?92=0GcIYZA+{C2_-Nhd5fM5)LJI=4>aiO;2fhRr7rGf;CdeE&wAOy7okwzvMOcj9e(5||NzR^HMW5`7XePlaYh zgJMS6nHd_QC7@bC4VEYF;^dea6Vi#`uH>YH+u`=(RA}eWC(?w*c{)^u)hl(quKX#J z1oUL1tpDfBDpL)4d#GX0Z_Gd6-hBPu>cH>q?nzN%5w?|+uJq+;ra7$g*Cb{t!<%Nl z_FuTa%Mr0J3`g~3E<7|F`+-V)CvJfc&YiJR*{oBz$^sumQ+*t~&k?O|F`6n;btMff z+BB0=A%cHVxkygju(=J;Gg|ORb_1*O5bgf#6V#@c5<;dbeRf0MF;CFi=pQ*-$Y44@ zauKEA9``ftK(_ik0I(HrHUx(aI=cg};E$zsUqegG7uUTBDGdbrm(ZO^cu+>p9fMfD z23TjXN4lrjecJ<+2L1D+!NI%SS_ZEFqqvd(V?r|tIbe1obS)B`;H@7vl*+PND(}nd>gpGo54|6eJakM&_T&Ke@SAqCG)S*U+d~ zB8CQC(;|b`#WQR12Lrk)`(o8qjXW?g8f(SX!%STVp<_R>$__DwEdwu)OA=6NHGN11 zJ#$kuX@R9mPUW;#e>G9VSPs4N*t7eHU0-~;ds%mzHmU<%)Mxd?>BX83 z*^E(Ke2w{EXbKiTKjQy|h6M6oSIG#DPlE@e$RU8eq}YHn&X_V-pRoQvZ@j>KRG_cW zH2RP&(dJ}mn|OLQ5MzC4SVd$CvTSR_b-drMI^G7vD#uHBQG{I!A|F>d)iDdT3x%$RsB0=L@0{=Ab?*6b)%`TpvQg_5wUocqxPGrA}S~hErrA~Q;`l_zwsCztH}(J7yh|@!_ce4JrlU)E8Jr% zwb;Dz2zd1ix+i6Jl2$ju9>H4_@iuSRETuwxFgw`E$c%@KA&4&;blDZP*JhX4N4<580V9tiWo}BS5{ag8qf`Od6y4-84uLB zKMYfKP21ajt6^-wgB0{aX)dKuNH|sf4cHO3-r9MbJ8O6MTOX<<*`dZw&NK7##A@*1xA^}aFND+y1E z-jA@i-x&V6M2RC-wB~+tD&wvbl1_EOk43=R8Yh4N72FT z3{M^8u}$$pWl@{Ql=TjsWl(baF^ven8=$5Nf2mWs) zFxowGQ}}l;gYkd%vL{&y;J?G&|G6pk(_j!Od}MGJ9Sdq=(h{w;`TXArK$dTordSnQN%n5(yN}=suQx_)VXRmx>i9LQ%PmF#h7!VJF)o&pAHzy~`zF z;;@Gidy&&a`1cu5=oDV`+kw4BluuSJDY8oS&z@aP zpYk2oWR?h^*wJI(!6R`jRzHLV8LIqPhzP~(;T9sE%_On;EV#6@_HyxzhFnni70O>Tl#}>+TuUg z$Pzz(T(#j>!F*o~E)B|WICxydjk78r9E8}rbR25AF|kj7!K$HEZ4IM^r{~JtO(1Rx zrpH}042HjW#C}EokBvyznL}az)rZ*s)rX8CD^M3HK;zg`bY!%rk?B`U@L&kqp7^x_ z&mMXlnOwubKm!}}8X642v0g_DOmeN;74~jZfy#R6*#854K!U&e3ZxB9(!4g>u3JI| zvWeT0b}8w)X79Dz+HKwA*FC##UAv_jsrNtMm2CL|%{uT;-*?XUo%5aVyubU+pTG1K zB3jSC%5?gF`0>5%@2nrt}g!vDa!Z(hufRDIn}&J#>?7$qXek zhG8W$L%<#}EE{J5-`={ewLO$Dj?_QkC1&zP&72i~H?}8J2Gdg08fqJ|^hC;NJ8J3K ztYNj?ZXV5~Og*8IhpGCIenf9e>6xLn-2;b=xT8@8Q$@-g8Zs=`ll7`K+FrsA4ImuMpEX~YPXRm9tl1YQ-^%%z-CH38Yx{+2_(JE?S z@?;cxQIU6v2Y_=EHOsyhroc4auiPd#(x?^s7&4r0W5iEuWN#bY1eqN(>WF{N~H#3*92Ng6-BeT*aS% z=kSKE#(g615t#EAw1c|*)YE^ z)K?K?@|VII^)gk$Qc+~DG)%Qm?AJ$s`@s^}ou=*hDdyttc5!=5%k?(Me30HDEB6#u zbZ6L4_qw}v*8}d%85Ue9?jjRRRX}dH^r18^;-^6u4}Z!w%yy@@g6aC@$Xwkg3XYAozHHDa+J! z^=tJGTjo)HAY~K;vV3F`5}G$r@G7PoW*F3kl4hIae~=X|TEJ*@6zN7;!-9(4B2iro z)*_joM(BPY9iw+5&eMkzNz3-r1GHLD$C*|}4GWnzXcMIMa+WJ_1ZRDKPKcsE2!}38(UBV*G%O>rJLe$yp%_C? zYxH5JYfI&vzWNc#vNNwQT6@?>57DnM)lIFIQWkc7%tsH>$EOECM59ll+90WCI?YT5 z*#s7axo6yAY7MtJNvC}D2t5jE^h9EMOfogjX|CCPM5a&EW1_s%NbhlfJr&QPyD<7} z!`eEeg5z-sj&gbe6<@F+h4#{CQ84Cas?pP6WgE_3JB4aB!nFD=MVH9*EIsF= zX9#Uj4Hy-8PnKa%LziqiL!XmPpNHzyX?mit!%x5NE`6~WGa}?p$H~qR^fyuUhlJF1 zsZMy5pI(r3@;sd_7A4q!u`5&J0KZkbu)8Dz&!ah*d7F-0GL?`tJj%3qj_bQlaluDp z^b&$VGIIvuwhT9%wYk6x#{Uw1Stz~&n*;|<3vnKiNO+kkG}jqd<@7b-^xHEi=O}Zt zDZeh1m*i)IkMbgaV2+%k(KnGkCan-P(a#o5S)#me!HO_NiKd}{&60#svVg;XkA7e5 z`v(a0A>|(^c1{GXtK|Ma5(7R%f6P>NphKfSg~NahS}kul&>;omJM?EhI!Av#trLL2 zhBT|+MdFI*EJ<4~YG#+=Vj;v|(qH-Ld-T^JNZMTx!Lo8$$4L0;ZzP9(kN&otD)^yV1*K;wLC9 z3x*Mkty$?NB&T?}d|GoVy)bJa8j_y&#$Vu=#r}&c*;mD0u0l`?f4lU6`An@>Yolg% zHfU^lA+A%}(o3hN;+1QBT+Owm8E^*aB19%~QlU9RX)npg)3ypjUc%S-crjl)?NQ)f z%hvch=*vQXDot8OkD2MgYt7Hg*yrP=e0??h7FM!HP>(~Z5slx5CMi9IVER_+2?aV(44a9adImbRS4K?pa4viG#%CH~??~48 z7V?vTPITW8t31Rl&~RNZLXUYLMQ z6F1i)yzzorm_W;IB?oe{(r2Z--1-Y&6LT19(wmp@8Zq%r?()t&Vt}o`PF#gj?Z}wZkYW(0%`h_V)@Znd?2q zn>g%$<96OGweC5Y-BRp!=jNjEZE(NB1>FGN3Jn(!dvx2zvpnJDPN|fgSFKXK4GD|! z2%0~j+sU{v>>8Uf$`x0*VMf-=SUs+zoF~>BGx-`&1zCpspFkyO@RU_VaGul~4$tI{pp=&9W6N7&HdL4iVYQ z2bj{q=`CK+l+IYsAk*6h%9XUk#lA-R|6;WXf+)B{`9!ePL0f}+)K?=Rv&E-^xk;&` ztzLcy6ecH{R`PyzpqKkmcnTtVS@%PMO7b|Aq~rwFC{|QuR$z^r*K3S-l@FTUmXVHs z-4c_PMwPp3WnmQ>N%A2d5A$KB6>iQ%DU1)xdj{QugTU9VM5hbGjnO8Q_w{b?$Fu&r z$gKT^ec)HF))-Hn1%?88CqGOq zkyc|adynw_K0d~H@ia|w+&TcuAkoTyr;lhdolO~C>2%JK_R0Mc!Vk>op0E66LKUcY z`}jD&2S0R^{InP4S39@bwtPJ@Gutdniq!|u{B;#lMbHpjIM|mlj%C3Qudeu1)(F~Y zUJSU$2d@wi>hKTy_yj+Q@RIe`BWP$%1n@(JH&1KflcJaLR$Ay{(o@Kf!OKj4yEUVy z4KIHJo&#WV@ey}Xx8lQIJ|*vc<=IEQ{3$dUYrOmz^2#c^ZIvBtSY>Zke~l$mFMkH! zijFz0qbP5hBikK#D@`!W_yj*Gj)7N@M_vtx7Pw~oFbvo$R+F!Z$&AsHOAjDJyO`<@ z({#knS+_{pRD*Ywh6d4tnU^YmF{_aeFlzMEd^Ikhg%~xgRs&*w8qgy&4>Uy4rf~|y zx<~1nJY9c*P_iS<7pRfxG1`Pli0Nf&i^X*k&dw2A3eLS{Qd^IH@I^sc5* zOP+4qe`ey9rWe4L`5FASl4pWysZ67QMyrV4q$Z8pG}=4?Sv)Ql5dMgNUIz+|E4{rS z;2Nq>0?xNVw4WQ&^=COtoFX9 zQF1OSCRMbK($Hd!8f$5Pu^N|9of? zHP=r3Kr=M=t~|Yaf9-pFIPBqY`53)FPalefE5_-;SXdjUhhiGqk ze@y5;lcy(ZpX$%kubmT3d_n#G##ib2$7n~(%k+GnUW{$2p>I5YMHOdg!#L$*<1`jq z+8-LF@jP9u{c4_GsjJA-t9kmyDE-dGNSzk;oT1kW&UxsI&Gz z^YnV2{wGhD&qmJB`bhB(qx5F&L{a(xGp0@I_7?*@4*)LIJj$MnC2TGcBnPiLkIZ2I z%gq;Qg&2@S^qLwQVW}0=qF@;XZlN|h30TI50k5S#1OBIME5CJMu9E- z3M`|*kJHl%mQmnm>2nH}QQ*(h7Zog{z%SypB(RJEU!<=qSVn=rL*G@fi~|3Neym^_ z1^x+_DOg6pc-$-&Sd9X=@;&OFW#X`W11+3@=w6NRppwYc=$uB6X!Ib_1au+`q|(Vb z5+wZkcPB7^E5R4GW97BU6%DWH>8uv31&zMVo=H3c{xWUg>Iunavlx=3jhW{I8K4Rm z30TmsNb^W5#ezpcB2o*OmM(!!n_>gnNk$~uOJsLZDoOFPU{HZnCF!b0zFZelP9ILh zBbh?*w`~EgxWN80B+6PBT%f>9R5Q-iF+q)TV2ta3&KC6!mSxM|p!tfukILvqFl)qE zBOlk?Bg!^Gm!kmmw?b7+jW#KInRXnU-u z&cma8>lr$V;5`6*fIHkB<#m|fF7uvCv?X+j+9km1%KH&xh@|Rpo_B=Ykm?amVNWQ) zT{!1|Wt6Xe@T~LnoS|TIo+GiI!fD83Avta02S{mWCcYnfl{{y0@Ld7!>Cf}t(-am( zb#}{9kpTB1A)Vv9E>Ts0@0Qf#4e-IRr>R-u^pybL(7iv{lvvJc!jXP6_Tsw(@)=&+jUKY;76myZ$#&4gXJ>o0)>l4@3xSi-{RKDagP~48P*CGY(ry7l6S*>0c z#Afj*k9d=doIug=xKUO_tfqNxO;jQCIxSN!aVu(gEnH#^T;eu(+P&a%KiuI_IK-2G zpdLf@|2BN`hj5-Zsfy7laTnaFmlm)A+8X%?TkzDU$*1btDJhJ~J0DiwiF4*&q+ z9+MH1Gm{%uEFFQ}exO)UP*ikPC<)t*qDh0q1f{@34W_jwJ~hLWWL7JGJixNRyW`__ zPKW6iLmAohHjdQoTwkwc6t@<}GdmjjuY_JHhRk_Cye(U*QgYewvv zfs9Qr&p~wm;ks-vS2cd=`G38}p(pjH$OvslBV!0e$MNz0d^YwaH# zlAUUp7FgJB?re2iFM7M}t?gE`*X=ahy}kWsO@V6bx1y8g&K?Qqo3`(TUiw&|P+NT| zF#k8{N#^+uDGe2Y>CG{>f^#UBFoW{~#f+`h2kcG9g+E+%j*^sr0u2`h$}XkPRmAWvrbmeLKR%X3cOCB@B>gw2MCINWgXa)AXp)j)eaAnj+O|MSXdE%Btc_z zX>V>WV{Bn_b5&FY00961001?P!A`?442B&FbnL`4L>xdYt6(5iyK#XNLIMfSUh1aV z(zHt2f`r(E@F*O303HhAg7CqzKmWh&ukVjf0JwmufcNe8K7W-f)En}JTuNQanbb|) zT8Eu&ysDdmm_64N$Lb~sGVRC%($Y2i~QW!(9Wda9d z1qtUJNPYlNO9u!m)M8Ti0000ilR*$2lR;QFf6ZBWd{p(dKWDZ(xful~1Q-?>LzKxf ziJ~GVA_fv5G6~24aoFO`%uO;fGdIo>hznJ#w)R=|wYD|Z`Yg4LRk~&4GS8oy7a>HmF0kqFVEq3ry>z7BzhI^c>*NX6OO5BJRIx6YQGv! z;4G{!uRFhPxi_TtSKMGHW|I9{DjrnVe}p3{Q>7N~sqcv^p@>?)C$9AMsqy-?`fG>r z)~1AG5?PpLUaj;i^${i3Q@^3>YBiXY$i`%eVxMWYXS;8F-=7prG*)e8nlZk*I-(>J z63I+uJ!*1eTuXuoSZvk|8Wo-@gGNFPrsCn`K>b9RMh7|QG?_~2bfz<>hm~k1f759= zXf>2&NX)cg(h=jkAnv3xna-eDOmnA#l4v$lDaiV?pl(bkCPy@;ChNCs@`2D?a>+D@ z<}o?)cO+WCWKC*YHnmPdYX#bwv`D6f+$V)N4}ke=(+Vk8h$`8>_ZCsFu7k)leO5WpEPK>IKdEjY_f? zMm(3v42Ix8oYsadiTUB)B{M+65BT4m^Ne>E7nBpeGT zFP)&9F_(5w3$2lTqY~6XR3J@ zh!V9yI(07`I0>DaJ%aHKw6T=h=?bQK<4kT!#ggHu+OjvO_8FLdrb|~Vv6z;0ht#AR zk0PtMgF>Z!P?ft|i@USOf4eVN;_mLa7Ig;^AYI61?j>g@mekp43-k!Ur~((cxQHIN z7je5{F5N*_3O@|Uv{@*8e!j2y2VzNOZyw`25V!efZSIY0dz3Drblq&b1eH!Bls3X_ zv800(VfQBLGJK(3iK-3?8Eep+ZAabJO1#oeJqY@`zPJXVlVLSsf2T0q3C52oB9X=u z5OaAEF^f1*F)4RbL`WHBT5@Vcba6DnWS^1b3~_{mIVesSiycI_JI`Z+kucI&G^)fx zJ{S}T2^C?H5|lQ|)K7a5T}mXP?b#CB9n<#2Ht1Rf6-GW7pleG2a~~sU7)FA6k zfr__Riz3^+2l~?be~g@XQPFzfo0=cvH0a_cx><1Z-f6ktkhS=&u!0irNkt+2=7By~ z?2No)^)vvI@1ysZ&~0=(n7_tO|AiEMO)9J=?esycG~4Me7&kGHNUBk18$E93w5s&f9;E?wQg^7bU3I8I@mlIVrV5`7AF+^}q7)que&oW)lN*{1a2xKGn( zgrf{iB7|*;e?AKVbcG~DsOEFKT8l)~T+Vxx4#@NfeU8cHDGp;qz!zkCh`uPg58ouN zvmlSlw4c7jwCrS|P`OHl35{U(r@FHH5*=b%>zT%J4eZ8=5R;Uf_P`6zx za;(Tw5JqAV*&lY$h%ssK-z7mSO88cszET%aya%U z^i!d-pD`_c_xKY10vRpKuCQ`b91@=EIR#z{x%eghN~YK8*P!NEnW)O@b46XXoqh|I zhGQXh?}l#p43yXEpf~9ELRWtfzT7&MI{zdp~n z6bYSM!K}{fKassEQ5!@thdVWg6C(aX4tmQdbN@oB&SH3X3WR^>CIX$GrW|IrwLBry zs3@PMK@A;AIF?wi4mdDqp@n{gO-yqpin1ydj)YKs8DkZD?QE0TD%u;H=&E8NU=|gB ze+n{<4lZFCB)Am$BdHmi4n7TS3>GmeosJFxX)&i>2hXIhK{I@Yu63vpMJuT~xJ)-M zWB##4Fij?V^=#1U;MqI}R^qvkQH!-}+1|jx^MrYB&;CeX5z#u7^+e|#ut>MOmnjrY78Ctm{?!RksowFhBu`X=cfk z)8!TzW*zL})3n_wXlgf-VROrxrY*kBoohEWHTzmRxAu&9dp|uRTgQ-Lk!?K}Pf46XWw{UoOBzu>HF*?>A?nw#QaBLD>gWJyUM=K7|nz|BN z1f#uvdBGph2Uf;pV~%LZ{2!z>f`vQ9MbBR3Gx+D-BI7qPCYy>PQe-a>TJ-w@lr;V@ zL>5VVNzrsOQHO@uAC>tY{us_Qq+lv~)sa1FbyiZvNbfwz_mu!0e-qC9B1p}cMV&1W||XGqFo`SvhZ@L@?54ni_)H8yvAZz zP}8t9jk+6)8Goz_e{6N|=lJt7S@{byY>Y9iV*K22tY6!$*86lx+SH`dtpvf_fW(g@ zF+|4~n4Zs13|Ty2^lBlaG9@aF#8afyO@%0~0{(BC#*x$GR!!brtwbXJuxL8@ARm(X zOPq#EGE7hWzp~i7yn5Wghn+->skAn`?;h_)+~WFHzwR5ae=B;jK{@#{)4U=_wZ;-j zC`#fZg_jXyeF>78=&hq&dOz~ifj6zrPR2|2BvkXGjEPtd z##NN!X8j)K;^{10nB^wkYx6VwtRVTE$lKvAJ3o)u2xAeBSfG8%vL7xyz#l_XBu{80T z!&n6yzvDM#{w@C=;w8ijyIyDV!>W?y+#ZQ zE86+5!fwFSN$9s6)3#07J5K&P|3hf!pLPr)`USUwryUVwp!x7@MuZh?Y zNrCm|8ozUE^)PMA(DtM2#d>vyt~yF49CSJbXeZ65O7hT3GMQxY1)40Qcr{71LZZdQ ze-f=61)%ZXL^Mh=aK#oLX9EEcJ58lJHNiZLhy7J}mc^$hLo~?+Awk`8pt>f2FnON6!0FThtu@=3_X^igCmpihh38MJGv>(7@?PdD^R~bH2OT& zf0(9M2FV392?l)4C3U9h=V|&)gLP>10QP^U@7Ia_nJd!t$7KSr9H4(OK+CO`f2;JT z*V6P4fwTumZ|X>Hfn*s6bxF2yu#Jz?+xO920KcOH+lHugghm5sAC7u~2FM0Gq;}cU zY#yXpf)<{~c$?|X(rzdbP$fFltuE^bTLZ3=&N7xV3{*#&XJC_l4yn`Z9Hg?Gqy`@+ zo^fHlyuoT+W-qt9q%^zspE&5Uf0o-VR|!$e?YgWDcAc)hkgm=SkOAYeH-N&>=n+`z z`T}+Z@u3sS)SP7@Rtl6fFA&e?yDWmOMI-b`pgqHG=iO;ue2_h9u7UBahOKF>c*s~mpBq>v-A~XBUYDkMS;x@mOL!@lTsCvLBm}Wpt`cUpbsD>eglE^3fAR7RHx6@C zgH;?E@OHYa8E#JV+A?lUv(Gr;I63g@vJLYU9WG12xesgLtK%SdxbU!Tko+!qYg2>G zxex2`KAq*AmYanG8825^K1Fj}HvP?<<{&5|4GfVw!fK$5dotX6)OfsFJU-4^2hJSk zgnoXx;I;w60LLXYz-PQ=f1DcTy;JPY&{u4rf~DN9A*?QE11t`yA*wFtb;n7v43Whw zHXBM@c2`MG5BdsX&gv>L7KZsf!bCTZ@GXIMp^ZBbsyS`oVOxgZH%JS;y459E{dV2z zcNm6G^If%R{?H&bj_^G|tVT2kYDf4c`2R;TeD6WNfBgtQ5NPvOe;?$BaMmzC+?nA= zYhAXQCwPSDi+Rbi)?da?=CUQSnVu8*E?O{3`$;l#p#IY@(SC`JN%S<)ziF97HH$7d zXOx^GtB)c*+Ka*hOn_J7?CF~!ijY1!?s2P(G*iUp8086p-4pkW& zm+>eC^A*jv2v+sFbE&8=`m1~>8o=qo2P{{T=+2MDjACuf)v z001i|lM$0MlNEX;f7?m}F%X9TShZSLYwO|0yH)Vib@2p?iZ_BND1w6EW!z4;(d>a_ zTQ9^uh;QMA2wwOAK9o3H6%iT8%>4Q0Pe|TBUf%$0VOHR=*E~ z+%SzZrDd+t#Ea7=v2I9{w8WcjX}z#b;jQh&*4=4IZK>gAe~}l<%u|I2(Z=?s445^+ z&wQ(+H4C;az4Zb~B9#ysl|-y|$yh#%^l+I5GKSgjYy2pU*>B>cTd z{C8PsNu@i6e@@9-88J~m`E|L-i`z0ayr&YC?+eT?{WbUxFJB6jmXxV4KyEo_RWc zNmddkjzSbL5jntzY?DBopgq-% zZQTR50fKCrme6e*XiM+X6Lx7!56ZTsExjnj`_21*$+9Dd-R?r7(R**+ym|BH&3yBe zuN?c#<3x0}`X{D4f3LrO%c_4`)EkTMHdB3zB8%evi^7ZI>A|5yGL}oEQ%!^EJ`?>J zGik=MCI$y$*{1k_8Q-1F4`vrd`eVtg8D2EBt7$Mc)RYhzrn!8@S+P~%&8#ZU@6RWb z=*SMlnwAMmYF8pSr}VA%Q<$23)JV->O`=Csz`C>R>Mx(X zML(TMf6GLR8^JG38ZRTXf4Z48INMzT`?)=n7ORH!twKH9Hp*DG_uk6f1XDrR0 z5$a2u*-$E-3&qo^Tr80a#Ztpyvvf+B+2+vte|y99mZ5 z8*cW{dYn&xIx`r9NzbV}{^&2Su$SYx{B$90^ie%^FrD!~^c90PF)e;$-_uv7%SWBm z&E(7`t~IqMb@=IGx!HiUBcN}61!J_~)Y-go zCz>T|#`Bp(Zn&ijfele@U1FCh*CBx`e+F}FZ%M^*-peYiX`e%788FRmO8V$(g2wJ# zT7oh5RE!xZsHy|4^n*7|Lt@5jnC4F&-#lcdHV=ta49XV6LTOaT7lZP+(J6CpM`|da zgK}mJYi_8kw9@6B(}^!`2P0*2pxR#A=c7F|TwHuIgF(O{>hd;&-i*tE9>gD4f8FJ6 zay{NcH~8o}dMgZ&AL@(cU`GQ(9UXF|-bOcyb#5w()t22lkV)^2^-A1+JLJTZ>8$Ce zcSypzj&6aRK5bmAgoxVar+3nABIIA0PMUf=ZTUp9Ptw%wK6 z&ZRe+`>Z*~U9VDfc^|#sM|aQ%e*};K{p1Gxw4W}KfO99h#IIA>_$hVm$IrGEfsniD z9?|7POik0f(=;>hlbIg|(8;BXy3VbusOtUn5#jx)^tHX&bTXea#Yg<~ph#Fu|HjIFsocRtT!YC_y1&wG4fV(1e@T6(Eln*= zMS=G!@(jj?Lj$orW~kRRQ=wdJ5OD}WZ*L+u(7ZI&o=){AGJ~PqKrDw3GjvAz4SCZCm%f1K6mwOKY+4q8InGRa72X%@YZJiWope9WDuETs z&4(n@(QjF+R~#yo&%!*hP#l}YcFS4Ap{!}@LkT5vS+Vw>1RN0YfA}CE@Pw~z$)|FO zK@;H6v_sRwQ&7jG+FVoAqcrBDo9Qv&D9d93 zcLYaGj`Q`GArd4t8Vi&l*5uppIeJ`t`3S;Hg>i#E@2A7`SsxJU7Z7mrscREMObaW5 zg3_v&PQr+o(Q;X0f5<|cPgYP;+u+MSdWyb+0BELilKGi}lknsERDRIRXeWZ!Pt(_g z|Lc<$Dq}I|o4B~B<+{=tbA8ergjC$~Zwqky7JUa%EoJV@*#lD}MF}%JL*Enj?;|fA zx1IG+bJ(CCLSw{-Za@G({aE6{kLX#%uW1Vhi6C1uF{ue-e-_M=IQt)f<=I%jjxQpM z>Gc0m1cZ{$(@%W#pY&5%83@-sxEr_#d;Pf;z%I18oEI<7UmO^qY z?_|30AJ2~Ef}r28=_N^z|0M@nna6T~-}>l9`W+Ir#ua6%wA6K0w*Isk`SQ1ma*@X(0-Nj&P z*5U?ke-w?wHog-G$IU=r+*0&I)NlhWZHZk)c*V^}m z%Uz-kc@9-|C1b7PbHp`p{i`qBvUXkP`kmW))^FLladX#t51%V{N}F$6*34{7OLUnz zrA*;9e7=|GvXr?OZ`pj&`u43JZi6_aI0=Rmf5kMfvpCmJ(av(I3oS)9LJV%IXzb^U ztiDZFpT*Z@^>tU%_u#|H*kEs8Y{|r}TXH5-{De7X^1=S2H`LFU@D}7Y?wRFcl#tbR zQy8ilxopvd#S^JL>D^{ar&hSj%*Fa++AId6u&f%K=wN!f+)wOzm@$y<+X$$IvSdkT ze>%N8KUAR@n{MLP;UV)M8?=@@@!b03N84k`WDmk540Rb_?&B!$nC0dx04m}bVY#3oUpLg)SizT04_O*4Bv{AM5Tlh+WRWEP8!>9q1 znEM8PE2i>4<8mV-i%^Ne)6J7{OuCV8^6|C&FTjGRdwR`wDPu3mC(K-Ocp|`JvK}a6 zUcQ;%;iGwc%Oslu7Mqd>-iffAO`7JA)V>!9`@izLe0&?<4xa#y<3WB8l(g#wKi`3x znXlvb;o8bEr1Lo`5Ip<=sZuMXf26WnllSBF8Fg(-2y3IjK%Ev#5%47{%6F8xRZ@WEQ@c}0XCA(9QmH>tJn#YNG~uH_wJ1LX@Pw?iicIeJYd>)%-Ek96W*(*N|T7v!tKCL`NkWezN>2V{@4z55V*jis+fxf5^YV~{|+D76HDezK|u2+JA$3I;lN+y zFZt*WKFYK*RF^T~`Lfp1f4-1Zec7;8A;wZ6`Po9sP5A*zicfJda1CbbJ^U0Lwi$sY z=V6pR{&i&~w}+pe@&q#N!*01-eqG%18xz%8DY_n4Zu$6I0)xH{`)Bm?<<_*Hzbj?l zVtxj=Zf!0n5Bbow1?pPw7tNvKQ7z4S`1@9zA_5$BG^Y_re+<`7e`Kvt`<$*l_&@v< zqSM5qo|m)LP9gZi&p!o-picf7CYu=)u7Q${7h_i?B+ozRUw9D}e+k4QTI4fk$vs{a z2+_&EF7ws0xdx-)FovWfney{*#o>O#C@%sPUrzk|dkc~LLB|NY0%f4xSNKoT|7U2R z54sIUxw*f;E9>XKe_BI-^Ydr2c2 zfuTaJRvZrET9;>xOcug>a>#hGOkcaz&Kl}u3zP3$h5d9M$8YmNp;~pSSDm63GA*A7 z7Ud=4^pkaPRHymW0<}o0tI~8!ReOAiPt8|L_3erEbtY1#U!8$yh0Z#BNC_F72$TI1 zi>#A|BKe_`Qtwps3-M>Sb5=@;7%cYf7^dY;qj%wm?aeuR?vY#M4g^7Xc!QWa1( zhHam%R(RD}s#U+)cBc0NJn53*^t$7@E7e?Tl{yC%j6xY<>`ee|?9F-oKp%o2j@wHTl&Vb-v6;5#j4ir0guHZ)@^D#(@AyHJ~^nU@LV?Ohsh24XSTNShGjvVaxV( zAKZHe0!4gxS8QmjD8V#KE@XFpu-DAkJ=GIG=;?ANOS8#E2C|nE-#p~Sx7qTOyrzDd zq3KMT1?nZtM~l^7w8G@Z9HLM|G`eGy<{qYb(M_XtQp2MZYv6GvKT} zK(!44=kjR8RiM@T$rldd%lX@Hbn?jf&%&D;9wS_y{Z#Ax92wC~j>euj8yd#wqD_aX zGs+F4wD}-d5G|bdmh)_z%Hw#9qKclNf5qrr9F2B0%(;A&c0kSjR2SAOpB6q$SBh#o z(e90Q9i>^}$DQ|)H|$tGLQa0NTz=ma`SPuS<(t3-wGW>$@{hy*3V-W~#$gy`&|UypQEkr4^d51(B%lY3-m7|WZ+~!5OAw|E8pfy4XXrWnUp%z?$nQEve?fz;Ht0Ho zt{=xdjRMgmHt6PYIU;>Vi}k0?2f+O|@{Ch((%|(5z$%7b)9rEGIag^E65lPSJd>Pd zGqiKG2;0r}qGDFul#&r-`*YAt7v>~gZApPK%R(p9;JrH z$Io-DsXgTYg&J#@N1SpEe;R@~ryXPT#XHI6c;H3{tp5}6KkxwNv$h9TIz|D5{+;>c zze288$|u`{Ua{lLBZuiJxlyC^)dGD(ZtQnOwRhsn4;WYON8%HU;S-Hb1$u6bex@(p z3;V58`sMbK@o&JFdc#eJ>BVTb^x01Mn^AhXK(D|Rol{)#E}B(zf5pFzjQ_eRYP;X7 z_%kwo!=@&orPIfK{`*7nQRWcUxDGR@O>c3orY9kg30*`>HLasoP0yh<8Ts=aTkE=>dA6S^)ZO8HOxzb4jRE!i&8KWH~2B zb)=f&Z$9{0A12%iIvd{ny5#|``@xF5qF!ulXgtJ@u0ymC*Ora#XcX9wBd_6^1)dXa zb={3HK{JESfXjJRflrD!gBS}I$9zFF=Qf=)-kRp3)1f3C(TWQ_5m0xylYYxoRA zC&Y~)zSbAGslaC*=H=0#;cSHqyer_|Im#=lmlSxVM4zB*C&CW`imXltaN-9LP$DjP zVZ?oiS3^(@pQpo%zv&RKjTj>|H;BG=>EoBtw;^IQ2HinJ#5IbvL)=jkC|)QM;m0D< zIK-XNh^HvBe@aAlk5Em}QSL;q%7S<_lhLz%Q1R;r&mKHU9^(gN~U@guo9N{kR z9^>p>&JEV^o||GTog8ro-A_`F-8;{D$KAByFz<^-f8|)N$6oj?1%8_x``e>k$dN&J z(-_~1KeOqno5uLvBjno@ba#zVDhv`qvUnJ`Jr;Byr8C1#L3hJr{9cyby&rRA^;;W| zr4PzbSs!;DDJqT~*YF4Rj`tM!t{T2a>}E{bM?=_FqBJY$!G7=6`^Cf$Pq3C4jARNr$B)n)+?$BGsko7PUjuD^*O>xXNp~Pu(umiX(g<1;!Dw z5E2k8#trrwJQwMNB_Q}-qq#hen>uw==bP7Qf5<~0)A^~IJjZkBK^<`}An)uC-zN2#>}2fLL5JV5jKX*!92NAp!T1yRi{PN%`8TV6@u zqt$Z{Rn_q4Bi<20rBFM@Pe#1*;d5y`7^!juy*{JqWiE`Yb)K z>5KF`O|64@%qmS>`VVf8)PK6*nCM|0IgHm(_{bgcmJJyEh}>%af=0Hd>&UTCaBq8REvf|TY{FA6af)@Y@juIT^V9@n2|0;;CmkRu{9AnUVi2oRI zJ6a8QtEW-V^#qLlWt|07R9(Br2a)csK_r##Zcw_rLAtwZ2$2w}p}QFvNdYP85Rgvk z5GAA}MTC3gz4!Cuy|dP=v(~KNJkLIR&e^liUip^Q|q+9Hja<|ou{uEgcWI$y< z4fN951)7ONi70wBe#u}OU*G5)^$ywgGH zBs+)dSNj}vnSUY2=q@=xPSjsoiJ?zzurn>jYlJW__xK0~FRaYPIo=3y`&Qfcfa0Nh zjHzd|AuwXiIcS}v8=Th|=&QjUru~o_bZEyyLr);qch<^2^PW<3O za}rW0fF!{MUNoE%>Z0*)c*8zT> zASW94$}~}RnK^axY%M)3xp43&S%&Wn>_{r(Oj0Z7<+Iye5e8{bRZnqeim7%?pFa23 zpAw(klkyV4+g(p6RrF0MUA$CgiDDU=v(4NRg=Au%U}d(a98R7-YFC{?3{;zvzUEc# z=wJPk_gFS4D{o>y?^y8~e}Vm&c~XbJd=VTalPQ#3exODL$i_^rR3A(Lhjvv_*y5iZ z)1rmI!B{PRr%}z0N+*5~I6zE-RNqG^{EFQ5TP9}OH-)8n8p7%!MZwho`Wq5MKIc?m zD_jAdnVcnb!jP;0=bQ^UO~NWqv@679g1h2bT(tYPt1aH;3-2KzBKYSnCR{`hVIo(W z`K?yW9vYU((OoFh4hH{lS7IJC!*Bi^A}(+L z&xCa{`Q==T>!{TQSGu}UUcJ+}jyw0L4{gGo3{tAU-03%@&5x;6f=!XaG$l8bo5A^*6%gtl8 zV^Im@ykbFnf*H{7QEgofiTwh#nREImZ%)2?Mr-YT_&adOfz(4FBs zaQtv;5p^Uv*EkR3lnF-dka}ajfdUEXko#>WBK;cI(dtQSM$k8rLqRC_$?uCf7tm}C zovWwsW!sTXwVX$MTSa?Smhe@W^Of_mx25GTla(`gufGKLV1jQgLre6K zdLMOnBp`Db)Co_2xtUGUE>efei`QT>Cum2;&V|06W8_e1w^Gk9P&BjEeI(bDm}@0c z`8|ouZX1tORFjHpUE8XU8tcP@C2wryOdcIuUSel?7n7K@1y)|LLLOqvLP5lbfiRm1|T_xz0wn2PiHBrAg( z5*^0|R5UxU^nO8G`hFUN-$)M_IF-KvoJ~vSd4PhOuJv#%cSL=MaQV zt}l5sHU;oDek7fRx~wcG0yb0Z206hpjg|F&8@>f0)oWvi7YUlkCrdL}Vi6Azx1w;a zEc77-W}#ZqIP1{)N;TKQ$0MS%kH-gG40v-6<@v{;q-!p@xBU}ch;m;{g1W}1Z7?Ar z{*$C#w%pcLWYD3Y?jt;WDmSki(id~u`JLuHKb|0GWc0mZcsFY~iB;Oswd9`>FlIi$ zc!t49Dzg7VVQ^^Odushri!X>4D-vOgL)0Vo6H+{tQjVWzuCL{yE~i&)#H~&jNtE%l z`?ot;U^8PFt3F^sWB?7Nr3B12{;8(ggn=R23CaqHlDHA36ug}Mw%7X2YSvq-{gOo- zFx0{*3`uoDBX5Qj`GMS>0^#VWZOOI>3_K%W5Dxw9Gb1@kubI7S6Um?;NZ^C{{;aU^A;67wDK1-Q$J-RI*7qx{a9uJ1Iu~6lfu^K|@lBvDMsNT&>(a?W{ax>>RD2 zYURX`H(G|e6xxJ0*lG+aQi_bp8h!owq*82+_2dO4hQ(o6YG}lo?7m!r^2iL_n@0I8 z4REnb+69!L?+$rV4zqPQ?LQA(j`%Olmu#BojEpiH7`Sar+k*sGS2}Kg4_AZOEW+0Z zdmFClm=$ZcrN>pO&uhrvRAy)msjyCYI7s;d^#EN%r#Wk~LdwwRcP2#^ zi$@R9V7jsVOjFJz{VUwGDy1Wq&qAL?Z|hu0>0IF=Uh0|YM9YNT6C>#I{4f7V-P=qN z>^N4?@tLnic9KrFZ3$;KRZ@geI}4^L^{vxOTk=slFLnA8{?EX}eC{i#8aBu1krK=Znn%^| z5y3E*TUwzyHi6%RAL5Q_30UrtW-Pz*Mgn zkv;5UoS?t;qecS*8mun>=0r&SNnT056`mYwqmB|)5b5MoY#Bhwul4jYg!=n|2NPv? zCF1u*PTeEwiO+vj`k7mF>2rJI%@r7y`(>G|Ifjp7_DGhO36VEE2EHC~9?0#1&ld8_ zlRA52n^B2to)Y=&7$x#^kc%5Y4r(d&q)bePxdidiNb3d!Zd73bdWp|2W7m^iCK<+u zh1khyNJDI|ac|>Cdx8SlzO7@BF9w;jjf#xFWR+Fu(hZ1tyz4$w%SYc9R!uhgo=~xU zXNPyN#FVDBo%KIZUrN&{Iu~X>~ong=^Mhu&LOv=<}!Y+w2tM=vYxw<_*<#ZyJO? z3B5gb5@2T>45r!l(d)x4>=KT_6DgdQps;wUv!E65ly zZJKySk}mZ+`$i{gJNb&k%UTb;Y1%s>#Pi|$lt+@%$8;&rHI%E;0%|)Hq1!u;%7eUf zehxq}*BS-bFsBRA+E<~FB@`t9D=lqVVDT`rI_WDoA@*avoTg`#zm$_gEU3fK#?|1N z-#mK-=)W5iG-#hZ4tjNCTVjxMRfIDR=pVp*+JwzM6*tyjj5dE;YWDbcaC35A-GQ*w z^{9v4WhFUAT>nX%J5yUlgSECjU=d@-XAn0mUAD7DF;W3}yhKv97MgFCgYr|sSW6|` zo3>`wehI6$aHj4AYh_57D-w4Y&fF$v)B!tfXxzico%XwFEcR{OL|YfmRo>1xLR@%# zv@toP!D&ilGdm`2hFR|ZZeO?80#M}4D0Lxm%+obi_uW6yQM`;eq;36tY9F_J-pczV z(Tp^pZ_F3c1xRqti~G?ty}%F0l$k-3S{1ZYeAM&NIc+Pt##L>hS+-*35}7ic`a?is zId~}Mm*47-no*9=7%6*!erL|j%Vu&n+x3M?ldqd->o@mGAy+w5xFg;lJH1JrbKU03 zf2cX0(Fq+H$2>}$CwUkX(mVO9h`+fyQyA)A9=|S1dBkQp>a*cR=$@-8l79!ZkZ-|Nihb%#`4BcGbmZXq-sK zhoKQlh>0?#u>>RzRTpjI@H<6TCzYDcJN~#QC1L_4tfXP(m4;nJ%dmXaeY3q4ULa6bzK~ z@_EUG>1|p+(zENTc+G{ui;44b{i!k)4tDu15N{e2Rqy?WemrBROC>WxTh^9o-%Q&C z#Kdk0sEWnEn+g4vN8z5#N?FOyAlrKk-G0nJ-W4`@v;X*3_{&%?KDt{KPt^n!X}0P1 zT4M~m1$|tW<(A3Zx7*HB7eFjk$Loh3eFr>c?CH-m|DUl*B*!x;0+bGteRHm=(BSns z0i!@uc5(6RIRihF!MTP$ajib_Ns?PNtJU!#o0LFJ_B`z$td)T(gYVCN2+~8|iW_@lrlF>tADBp;beOX=0P|1D$<+OYJgdF+it{Q#&g7!upkR3EYxtp#-QUxFoiaw&- zIQoEyI)PeqXe&`6u3;*RyLpmYrT~JH;}bnWrYAgaOA_0T72pR|-ybS8#iwQwqa$<& zl~qnL)KTN6F&l|qrijyb;I4d^OSK#hytJ}e{q(DS^OyC?Cw}ojU+hl*zya6m`P!}n zVW}Pi_vCLH18Kq>1Bsas1=eZbyR z*t3vM%w2E9(CBc4o7b%U;WTwD-l?s;4gQ)cZ)93 zEayc;tV~)){%GWG8R%4O5Il&Y_GPWaHLHypd{eC^DeQ}^=VGn#oHSE#sOeQLy5vSD zM}ro<2oW{QDKv$dl&zsDbwPz=c*%hbvvQv@i0EoF4t8Wsq`Sd{pr;&y>ErD{JSLd5 z`rJw)1CNLj6=NU=b6H!k6}%%DX(%}I)aJl{HWGuz?T6~KaT6LM2$DLJyGG2rWYVfC z!I6rjN9)67HwEybOvD9p&*sGFvN@H&;NE6O| zIE{Y2drFoW2{nB9B>c-z?%d2HvRxA)#`k+a1$ar=;P3PN(Vhzwf3upv=PISnY=oEM zC|p-+`^v8rkhOhz2EAM-Ys*%VZ?l!GukBZM@QlyhvhF$#%~XY8=##=SyyKm!(J3R5 zoRiNXlrjDhL&TQPZ_M`=JVtyM1Nx9ZZeDc9_0?nt?oVNgmIwyU>T9e=u}#E;bz(&~ z)*@|oU*dItKG`Vd^J}K`LA|6RC43D^JSiX2m1#$WqbLnY_8T(^dbSXdeC{04NGY2n!oIk>nXGR0q9(y3sfDlcofP_wRE~g?f&NH&$1u+6 zNc-w;-nGrKu6S;j2j+U77;}IOi-!?KsmxQC?9E`22oh3Asuus?ic$L-ZmNSm61#S$ zNpqya_Hb}^C$lHkbn7f(n~WDp?|2uy6VB4Av(lLr+!ptK$P1>eeuOP;G0D+K7NEKm zcewF8Giju_Xw7B1O0z+DEE2+Dq+Pu;b4XbBjOEsHUdRl2fTyT4e6xicAG{^I%!Irz zqPlCBQx*pCuX6qno$dZX6(gn*-TQU@s&NBpYx;kd~2Tw%nUZe}W zaQ13_KJEUDvNu!e;*oaZV*MTEnHFd9g*bWWzQ0ujBgUSinohs08R6OCloP&D+;wtB zEy?if&M!UUZ9m>*4pcllEI^?aMcly7X$xlUkkyKT3{A@(c7;lLryCw%8o(l-o+_P< zQ;C{xS3egqSxS7w$HSBljgL6}&E82ok)3AUafs>jigvx1?t#rjCTaRJ!&A)E?5;X) zJ@wwLvCVK-8n>f0$;sDB>$=bDFG%nTRvRXypF3iD%D$dA>OGU%Mf?7Er!=Xadt7zW zSryNX8ghVfo!X07(zlA_Ov|XBG#QGQ6icH-{rOZ}r&s+LVzb7Ubii6Eg6kVqES^$4 zu+l$t^UjxP{WqzCdFK=v&b6RW#5Y=~uiF(N`Sx-H@sw|v3CYyK$0KotW-Kfcc}KNz z*m!F+qwmIpRD7EPF5zuzXAvGi^OPX^W>n4>A5tw7wJcHD}=)fPB$|#w~_iHfx+y}7xN51D7W+3 z5=9)!sHX-WG(DTaGJLNearV{iRD2%8$1=HBGX4$8U?j!FUofL8Cw{3=+d}mNoR%?# z1;~1mw?~u57U3v*C!v_2U^t#hZ~JjtTWp%E@k@f8huzE%^7ga4e;!ds|5j!7M%W*+ z{=ri7KDov(iNdZ;MQuV}iH^^2tjI3IhsOQUo5X1Cfr*dSnv<%1US!dqtc$U7Q(s$- z@1>rQC4ND|A113Vl*`DnKok*w@TWq+D*|<+?+_UT8peVW<>;f?j}05703syKP@fzr z@FvQh95wL7>k7O%L1@6||MvnGA0!LF&=c&((3%olaHU^Z?i*lfViEXjWw-}s;NF3O zxI}j#V!C;+0RT1vlU(k5U^oE)(n3`TDDFT<*|kXxAQ}_}1QNLi$`ap!Px5%c68`-i zCIBD=NCb)AgD}bOpv*j8uswC9q9Two4M=%E(hBt*gq_a=CXggfnE@aLz;@|=q-;6> zVuTLTQr)$yb;_Th0O*b&02a8Ha_h+*_$!|Y{MTy!-)DKo2tX>}J0Ei($|w+p>t2)) zK>h{jhS35h@PAuQi{FEi+3q0zLVoand9ePg=L0zbhz2mH#`)85%D*0n|Ne=(`9M%y zA!@+3^&j#-Hhw?@S?=X65dK5*-F3uYld6B8qpm0vub2Yx{o@AzHAVuIp6`WPmjWOo z%D)yN|GvV`o`In3lGGUgWugQvD50YK>o5TViQNmytMKO=a(Au!YjywvvEQR!DnY4X zL{L9z`aAHiPW!)G=&N!E{^OCrb_f3ztpW_+Oam4*aWI1p-mu1L4|tpotR6 z9eAgX1oU$qa3QeX15d2(Knfk&JMcg1KmdIIR%Exm0|8YfL_k#udXqy7{#$ni0x{i- z8fSM0la_IS|GPNoe(K0T00Kk7{@}aRf0wvHpvU*3X$1qIF!(KSkX_lF3}+&gF!#sdEP{0joH-HSw>{|Ea2>un6+zf<~ucVT1kUEs2EZm?Ix h{|$2>(9?T?Ens&LMg;>l4TuI5i3I}HR{t61{s$ARIko@* delta 35349 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 /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -173,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -206,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a21..24c62d5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -23,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,30 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From be58128a0c5a95be2035751b5ee739546334e513 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Fri, 1 May 2026 09:19:37 +0200 Subject: [PATCH 05/11] chore: Update all dependencies; min IntelliJ version to 2026.1 --- CHANGELOG.md | 2 + README.md | 1 + build.gradle.kts | 58 +++++------ gradle.properties | 6 +- gradle/libs.versions.toml | 40 ++++---- modules/common/build.gradle.kts | 6 +- .../common/OkHttpClientUtils.kt | 1 + .../common/CryptoUtilsTest.kt | 36 +++---- modules/java-dependent/build.gradle.kts | 7 ++ modules/kotlin-dependent/build.gradle.kts | 7 ++ modules/settings/build.gradle.kts | 7 ++ modules/tools/editor/build.gradle.kts | 7 ++ modules/tools/ui/build.gradle.kts | 4 + .../tool/ui/common/TitledTabbedPane.kt | 1 - .../jwtencoderdecoder/JwtEncoderDecoder.kt | 96 +++++++++---------- .../tool/ui/frame/AboutPluginDialog.kt | 1 - .../tool/ui/generator/BarcodeGenerator.kt | 4 +- .../tool/ui/other/HttpServer.kt | 34 +++---- .../tool/ui/other/JsonSchemaValidator.kt | 56 +++++------ .../tool/ui/other/RubberDuck.kt | 14 +-- .../tool/ui/other/TextStatistic.kt | 9 +- .../ui/transformer/JsonPathTransformer.kt | 2 +- .../ui/transformer/TextFilterTransformer.kt | 2 +- .../testfixtures/DeveloperUiToolUnderTest.kt | 20 ++-- settings.gradle.kts | 2 +- src/main/resources/META-INF/plugin.xml | 1 + .../CliCommandConverterTest.kt | 24 ++--- .../plugin/PluginXmlTest.kt | 7 +- 28 files changed, 246 insertions(+), 209 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3928f2..b93f248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Changed +- Raise minimum IntelliJ version to 2026.1 + ### Removed ### Fixed diff --git a/README.md b/README.md index 005f5ea..3e6862d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Plugin icon by [Gabriele Malaspina](https://www.svgrepo.com/svg/489187/toolbox). - JSON Path Parser - JSON Schema Validator - Hashing +- HTTP Server (WireMock) - Archive (ZIP, TAR, JAR, 7z, ...) viewer and extractor - Date Time Handling (Unix Timestamp, Formatting, ...) - Units converters for time, data size and transfer rate diff --git a/build.gradle.kts b/build.gradle.kts index e87f257..155016d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ + import org.jetbrains.changelog.Changelog import org.jetbrains.intellij.platform.gradle.TestFrameworkType -import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask.FailureLevel.INTERNAL_API_USAGES import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask.FailureLevel.INVALID_PLUGIN @@ -20,38 +20,47 @@ plugins { alias(libs.plugins.version.catalog.update) } -subprojects { apply(plugin = "org.jetbrains.intellij.platform.module") } - val platform = properties("platform") allprojects { - apply(plugin = "java") - apply(plugin = "kotlin") - apply(plugin = "com.diffplug.spotless") - group = properties("pluginGroup") version = properties("pluginVersion") repositories { mavenLocal() mavenCentral() - - intellijPlatform { defaultRepositories() } } - dependencies { - intellijPlatform { - create(platform, properties("platformVersion"), false) - bundledPlugins(properties("platformGlobalBundledPlugins").split(',')) + pluginManager.withPlugin("org.jetbrains.intellij.platform.base") { + repositories { + intellijPlatform { defaultRepositories() } + } + } - testFramework(TestFrameworkType.Platform) - testFramework(TestFrameworkType.JUnit5) + pluginManager.withPlugin("org.jetbrains.intellij.platform.base") { + dependencies { + intellijPlatform { + if (platform == "idea") { + intellijIdea(properties("platformVersion")) { useInstaller = false } + } else { + create(platform, properties("platformVersion")) { useInstaller = false } + } + bundledPlugins(properties("platformGlobalBundledPlugins").split(',')) + + testFramework(TestFrameworkType.Platform) + testFramework(TestFrameworkType.JUnit5) + } } } - spotless { kotlin { ktfmt().googleStyle() } } + pluginManager.withPlugin("com.diffplug.spotless") { + spotless { kotlin { ktfmt().googleStyle() } } + } - java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } + pluginManager.withPlugin("java") { + java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } + tasks.named("check") { dependsOn("spotlessCheck") } + } configurations.all { exclude(group = "org.slf4j", module = "slf4j-api") @@ -72,8 +81,6 @@ allprojects { useJUnitPlatform() systemProperty("java.awt.headless", "false") } - - named("check") { dependsOn("spotlessCheck") } } } @@ -86,7 +93,7 @@ dependencies { pluginModule(implementation(project(":settings"))) pluginModule(implementation(project(":tools-editor"))) pluginModule(implementation(project(":tools-ui"))) - if (platform == "IC") { + if (platform == "idea") { pluginModule(implementation(project(":java-dependent"))) pluginModule(implementation(project(":kotlin-dependent"))) } @@ -152,7 +159,7 @@ intellijPlatform { recommended() properties("pluginVerificationAdditionalIdes").split(",").forEach { ide -> - ide(ide, properties("platformVersion")) + create(ide, properties("platformVersion")) } } } @@ -169,17 +176,10 @@ tasks { named("publishPlugin") { dependsOn("check") - doFirst { check(platform == "IC") { "Expected platform 'IC', but was: '$platform'" } } + doFirst { check(platform == "idea") { "Expected platform 'idea', but was: '$platform'" } } } named("buildSearchableOptions") { enabled = false } - - named("runIde") { - jvmArgumentProviders += CommandLineArgumentProvider { - // https://kotlin.github.io/analysis-api/testing-in-k2-locally.html - listOf("-Didea.kotlin.plugin.use.k2=true") - } - } } versionCatalogUpdate { diff --git a/gradle.properties b/gradle.properties index 3c6b291..280e476 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,10 +3,10 @@ pluginId=dev.turingcomplete.intellijdevelopertoolsplugins pluginGroup=dev.turingcomplete pluginVersion=7.1.0 pluginName=Developer Tools -pluginSinceBuild=243 -platform=IC +pluginSinceBuild=261 +platform=idea # LATEST-EAP-SNAPSHOT -platformVersion=2024.3 +platformVersion=2026.1 platformGlobalBundledPlugins=com.intellij.modules.json pluginVerificationAdditionalIdes=CL diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa8dad3..a430211 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,33 @@ [versions] -assertj = "3.27.3" -changelog = "2.2.1" -commons-codec = "1.18.0" -commons-compress = "1.27.1" -commons-csv = "1.14.0" -commons-io = "2.19.0" -commons-text = "1.13.1" -cronutils = "9.2.0" -csscolor4j = "1.0.0" -intellij-platform = "2.5.0" -jackson = "2.19.0" +assertj = "3.27.7" +changelog = "2.5.0" +commons-codec = "1.22.0" +commons-compress = "1.28.0" +commons-csv = "1.14.1" +commons-io = "2.22.0" +commons-text = "1.15.0" +cronutils = "9.2.1" +csscolor4j = "1.1.0" +intellij-platform = "2.15.0" +jackson = "2.21.3" jfiglet = "0.0.9" jnanoid = "2.0.0" jose4j = "0.9.6" json-schema-validator = "1.5.6" -jsonpath = "2.9.0" +jsonpath = "3.0.0" junit4 = "4.13.2" -junit5 = "5.12.2" +junit5 = "6.0.3" # See bundled version: https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#kotlin-standard-library -kotlin = "2.0.21" +kotlin = "2.3.0" named-regexp = "1.0.0" -okhttp = "4.12.0" -spotless = "7.0.3" -version-catalog-update = "1.0.0" +okhttp = "5.3.2" +spotless = "8.4.0" sql-formatter = "2.0.5" text-case-converter = "2.0.0" -ulid-creator = "5.2.3" -uuid-generator = "5.1.0" -zxing = "3.5.3" +ulid-creator = "5.2.4" +uuid-generator = "5.2.0" +version-catalog-update = "1.1.0" +zxing = "3.5.4" [libraries] assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } diff --git a/modules/common/build.gradle.kts b/modules/common/build.gradle.kts index 3517c7b..36fe933 100644 --- a/modules/common/build.gradle.kts +++ b/modules/common/build.gradle.kts @@ -1,6 +1,10 @@ import org.jetbrains.kotlin.gradle.utils.extendsFrom plugins { + java + alias(libs.plugins.kotlin.jvm) + id("org.jetbrains.intellij.platform.module") + alias(libs.plugins.spotless) `java-test-fixtures` } @@ -12,7 +16,7 @@ dependencies { testImplementation(libs.bundles.junit.implementation) testRuntimeOnly(libs.bundles.junit.runtime) - if (project.property("platform") == "IC") { + if (project.property("platform") == "idea") { intellijPlatform { testBundledPlugins("org.jetbrains.kotlin") } configurations.testFixturesApi.extendsFrom(configurations.intellijPlatformTestBundledPlugins) } diff --git a/modules/common/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/OkHttpClientUtils.kt b/modules/common/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/OkHttpClientUtils.kt index 5a20736..5a9216d 100644 --- a/modules/common/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/OkHttpClientUtils.kt +++ b/modules/common/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/OkHttpClientUtils.kt @@ -29,6 +29,7 @@ object OkHttpClientUtils { Protocol.HTTP_1_0 -> "HTTP/1.0" Protocol.HTTP_1_1 -> "HTTP/1.1" Protocol.HTTP_2 -> "HTTP/2" + Protocol.HTTP_3 -> "HTTP/3" Protocol.H2_PRIOR_KNOWLEDGE -> "HTTP/2 (Prior Knowledge)" Protocol.QUIC -> "QUIC" Protocol.SPDY_3 -> "SPDY/3.1" diff --git a/modules/common/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/CryptoUtilsTest.kt b/modules/common/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/CryptoUtilsTest.kt index b32a4c4..c2d418f 100644 --- a/modules/common/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/CryptoUtilsTest.kt +++ b/modules/common/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/common/CryptoUtilsTest.kt @@ -36,24 +36,24 @@ class CryptoUtilsTest { fun validPrivateKeys(): Collection = listOf( """ - -----BEGIN PRIVATE KEY----- - MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzPj2X1dyZzDmqT/W - ZcdQF9i9vL+UN65wX5i2+Zm3B4TFjQtqYSbdTkCGhPrTfKZ1mH6fHnCqJce5Y4zD - UQIDAQABAkAcQ0hOM2j1dLD+Rl4nQUcxdLKHykHpeNkKccJcMqRm7C9P0hxPjTvV - cxMEk5u1bJJNV9IpTey4PPZ0ddOmFGAiAiEA8z3ibztuj9HbW1vJZTZUB3W3uyhH - 6uv3g9jPH0FcV00CIQDNzFqJk2ql+0N+2/tHkD3A0P3AUKPd0QPoRBDeyQ== - -----END PRIVATE KEY----- - """ + -----BEGIN PRIVATE KEY----- + MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzPj2X1dyZzDmqT/W + ZcdQF9i9vL+UN65wX5i2+Zm3B4TFjQtqYSbdTkCGhPrTfKZ1mH6fHnCqJce5Y4zD + UQIDAQABAkAcQ0hOM2j1dLD+Rl4nQUcxdLKHykHpeNkKccJcMqRm7C9P0hxPjTvV + cxMEk5u1bJJNV9IpTey4PPZ0ddOmFGAiAiEA8z3ibztuj9HbW1vJZTZUB3W3uyhH + 6uv3g9jPH0FcV00CIQDNzFqJk2ql+0N+2/tHkD3A0P3AUKPd0QPoRBDeyQ== + -----END PRIVATE KEY----- + """ .trimIndent(), """ - -----BEGIN PRIVATE KEY----- - MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzPj2X1dyZzDmqT/W + -----BEGIN PRIVATE KEY----- + MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzPj2X1dyZzDmqT/W - ZcdQF9i9vL+UN65wX5i2+Zm3B4TFjQtqYSbdTkCGhPrTfKZ1mH6fHnCqJce5Y4zD + ZcdQF9i9vL+UN65wX5i2+Zm3B4TFjQtqYSbdTkCGhPrTfKZ1mH6fHnCqJce5Y4zD - UQIDAQABAkAcQ0hOM2j1dLD+Rl4nQUcxdLKHykHpeNkKccJcMqRm7C9P0hxPjTvV - -----END PRIVATE KEY----- - """ + UQIDAQABAkAcQ0hOM2j1dLD+Rl4nQUcxdLKHykHpeNkKccJcMqRm7C9P0hxPjTvV + -----END PRIVATE KEY----- + """ .trimIndent(), "MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzPj2X1dyZzDmqT/W", "MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzPj2X1dyZzDmqT/WZcdQF9i9vL+UN65wX5i2+Zm3B4TFjQtqYSbdTkCGhPrTfKZ1mH6fHnCqJce5Y4zDUQIDAQABAkAcQ0hOM2j1dLD+Rl4nQUcxdLKHykHpeNkKccJcMqRm7C9P0hxPjTvV", @@ -64,10 +64,10 @@ class CryptoUtilsTest { fun invalidPrivateKeys(): Collection = listOf( """ - -----BEGIN PRIVATE KEY----- - InvalidBase64Content - -----END PRIVATE KEY----- - """ + -----BEGIN PRIVATE KEY----- + InvalidBase64Content + -----END PRIVATE KEY----- + """ .trimIndent(), "InvalidBase64Content", "", diff --git a/modules/java-dependent/build.gradle.kts b/modules/java-dependent/build.gradle.kts index ed743f2..6b50590 100644 --- a/modules/java-dependent/build.gradle.kts +++ b/modules/java-dependent/build.gradle.kts @@ -1,3 +1,10 @@ +plugins { + java + alias(libs.plugins.kotlin.jvm) + id("org.jetbrains.intellij.platform.module") + alias(libs.plugins.spotless) +} + repositories { mavenLocal() mavenCentral() diff --git a/modules/kotlin-dependent/build.gradle.kts b/modules/kotlin-dependent/build.gradle.kts index 385780c..19474aa 100644 --- a/modules/kotlin-dependent/build.gradle.kts +++ b/modules/kotlin-dependent/build.gradle.kts @@ -1,3 +1,10 @@ +plugins { + java + alias(libs.plugins.kotlin.jvm) + id("org.jetbrains.intellij.platform.module") + alias(libs.plugins.spotless) +} + dependencies { intellijPlatform { bundledPlugins("org.jetbrains.kotlin") diff --git a/modules/settings/build.gradle.kts b/modules/settings/build.gradle.kts index 07b90ea..7fc6edd 100644 --- a/modules/settings/build.gradle.kts +++ b/modules/settings/build.gradle.kts @@ -1,3 +1,10 @@ +plugins { + java + alias(libs.plugins.kotlin.jvm) + id("org.jetbrains.intellij.platform.module") + alias(libs.plugins.spotless) +} + dependencies { implementation(project(":common")) diff --git a/modules/tools/editor/build.gradle.kts b/modules/tools/editor/build.gradle.kts index 549d154..ada63d1 100644 --- a/modules/tools/editor/build.gradle.kts +++ b/modules/tools/editor/build.gradle.kts @@ -1,5 +1,12 @@ import org.gradle.kotlin.dsl.libs +plugins { + java + alias(libs.plugins.kotlin.jvm) + id("org.jetbrains.intellij.platform.module") + alias(libs.plugins.spotless) +} + dependencies { implementation(project(":common")) // This is required for the `OpenDeveloperToolService` mechanism. However, a diff --git a/modules/tools/ui/build.gradle.kts b/modules/tools/ui/build.gradle.kts index 3490731..925a92f 100644 --- a/modules/tools/ui/build.gradle.kts +++ b/modules/tools/ui/build.gradle.kts @@ -2,6 +2,10 @@ import org.jetbrains.changelog.Changelog plugins { + java + alias(libs.plugins.kotlin.jvm) + id("org.jetbrains.intellij.platform.module") + alias(libs.plugins.spotless) `java-test-fixtures` alias(libs.plugins.changelog) } diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt index 52d95a4..e2c31c5 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/common/TitledTabbedPane.kt @@ -3,7 +3,6 @@ package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common import com.intellij.ide.ui.laf.darcula.ui.DarculaTabbedPaneUI import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTabbedPane -import com.intellij.util.ui.JBUI import java.awt.Font import java.awt.Graphics import javax.swing.JComponent diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt index ba2421e..a7ffd67 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/converter/jwtencoderdecoder/JwtEncoderDecoder.kt @@ -10,10 +10,10 @@ import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.fileTypes.PlainTextLanguage import com.intellij.openapi.observable.properties.AtomicProperty import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.getUserData -import com.intellij.openapi.ui.putUserData import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.Splitter +import com.intellij.openapi.ui.getUserData +import com.intellij.openapi.ui.putUserData import com.intellij.ui.JBSplitter import com.intellij.ui.components.JBTabbedPane import com.intellij.ui.dsl.builder.Align @@ -52,7 +52,6 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.AdvancedEd import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.PropertyComponentPredicate import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.SimpleToggleAction import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.UiUtils -import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.applyDefaultTabComponentInsets import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.onSelectionChanged import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.registerDynamicToolTip import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.setValidationResultBorder @@ -179,9 +178,7 @@ class JwtEncoderDecoder( .resizableColumn() } .topGap(TopGap.NONE) - row { - cell(createEncodingDecodingComponent()).align(Align.FILL).resizableColumn() - } + row { cell(createEncodingDecodingComponent()).align(Align.FILL).resizableColumn() } .resizableRow() .topGap(TopGap.NONE) } @@ -203,9 +200,7 @@ class JwtEncoderDecoder( .resizableColumn() } .topGap(TopGap.NONE) - row { - cell(createValidationComponent()).align(Align.FILL).resizableColumn() - } + row { cell(createValidationComponent()).align(Align.FILL).resizableColumn() } .resizableRow() .topGap(TopGap.NONE) } @@ -629,8 +624,7 @@ class JwtEncoderDecoder( private fun sharedEncodedEditorLabel(jwtTab: JwtTab): String = when (jwtTab) { - JwtTab.DECODE_ENCODE -> - UiToolsBundle.message("jwt-encoder-decoder.editor.jwt-input-output") + JwtTab.DECODE_ENCODE -> UiToolsBundle.message("jwt-encoder-decoder.editor.jwt-input-output") JwtTab.VALIDATE -> UiToolsBundle.message("jwt-encoder-decoder.editor.jwt-input") } @@ -677,58 +671,58 @@ class JwtEncoderDecoder( internal const val EXAMPLE_SECRET = "s3cre!" internal val exampleHeader = """ - { - "typ":"JWT", - "alg":"HS256" - } - """ + { + "typ":"JWT", + "alg":"HS256" + } + """ .trimIndent() internal val examplePayload = """ - { - "jti":"96492d59-0ad5-4c00-892d-590ad5ac00f3", - "sub":"0123456789", - "name":"John Doe", - "iat":1681040515 - } - """ + { + "jti":"96492d59-0ad5-4c00-892d-590ad5ac00f3", + "sub":"0123456789", + "name":"John Doe", + "iat":1681040515 + } + """ .trimIndent() internal val exampleRsaPrivateKey = """ ------BEGIN RSA PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDdadLFj3DqaYtpZ1ik6ejpIIAU -2KhFqygTvR6SSS9RmcFQu/vojHWzQUhm8aqrGYVkDXCHvEcyBPcZUlWBczcDwQ5YF8VktRpMxfAI -K/OZRmfrhK9jAZsxOPCCXMOY+JoCbEqEOpsClbHKbgNBgw4AfsISzuWODa47KucIQad202lUZMQ5 -iBQ9CRcSfSis6HyvCMTY5li/9a+O78FfqIGUE4FHeJpsiay2z2AMEzwBPoURkTaSjjOT25e+GY7k -ntilnVne1ORdOPMnOcd28COex55Z+C4QlOr2UIDaAinTAG/0ozwWxd8OaVJJy3mj3dd3AeD2vBMm -ycnhrM+sccqHAgMBAAECggEARelztZDg2QuhixMoUM5RDkeGWc69d14fZfgpzowQRmZTvZ/V32x2 -f7bl2yeEucjxrxF1Tk67dkZOFa9DM4BDR0qusk8zM2Th3IsFizcBkIzEJIA9dvgbXjP58VfEJSme -S5SRBOaSaoME5APPwGBWy/46XoD4x912/dTCpX9Blwl81i7EO8o3NnYhsCWeVoUJTWBzN95OchZF -ozV4pFgv0tZqTNa7VhJtWHHiKkCpdK7gA9SeVEqEeL1TADAa2ngy3BIRfTgdAct6/4N+ZlVsaIXB -1Gnw2RaOoUbHy1PCA6ygtH5lz65p0JdWGcO5l+JNeYmOIeOdJ3QWbVI+CikPGQKBgQDv/JrTewDt -BK5KBpotFsrJeDFKOkC6A8aNeGliAEgJYvCk7zb8RtKCx7ViaYGYWJYj30oYejYEE+vFT7sgzXfE -JPAEiMw4uKeIrbX8QEIP+R25S8iRr657DkTxOvyhO2oQcC7UkZagvrVyQ17VgjtjxGWbc5bRBk5v -u1ZV9VMZuQKBgQDsL/PsLCX3YRBB5+0rpWoTKKrmFtGh31oue+d37Nd7oxBzb2uyF4Q29+zoy1on -EnHNamjjdR95NZoOjEsIIKDTV1C/bsS7be53m0mwKQfecKIXJ+7VN4UsYZXjCajHCr3NFHiIU8ct -pcKGtg7ga5cERIBtrPAi9Qzi7/o1MxUmPwKBgFuSaMWPZuAJ7DNE56mSy9gqa6xmI/KWpDmxG40Q -jGxAe5CD0thacdMDPzwJBDFMhCW1+wDyCRBvRYSpkr7GiA+pBIjGZh6ynwKxPgK9xjdwGB5vQ14L -yikcXcQqfOFM2YDiPYxQ7Ufy3St3d4VCx0SfWSIC7iZeIKnTsvLjxEzJAoGBAKcLFzou0z9N3+Cs -9pnK6OXZ+ly3QNZ6kF6V9VRlJtXjs0vhPsr7ROBXoq/WutEtg11j6AEPIg5o8adeY+bApN40QADU -h8GD84eWRZyYuF8DTDCSZqFYHhEQh6DGgR8dIrX7x2+ryRAozxbVhloE3g7/n9Fx4Xjn1ZBfZ5fe -pBOjAoGAcw2M22BK3NWOHhJ8EC4p6aUIR96lNcCWE/ij+MWCcRdotLDSDuT1q13C+UTxDZ5PsmDs -N/bhCDRZYZoLYo0/h6v4zKBDaX05nVUTCYux0Fo2HGrj5S0bjmgyRcr8+enA3CTzCHZPWZ7ZeADb -0Mbtt/Q4JyOCgwORgXJVQBHxxIQ= ------END RSA PRIVATE KEY----- + -----BEGIN RSA PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDdadLFj3DqaYtpZ1ik6ejpIIAU + 2KhFqygTvR6SSS9RmcFQu/vojHWzQUhm8aqrGYVkDXCHvEcyBPcZUlWBczcDwQ5YF8VktRpMxfAI + K/OZRmfrhK9jAZsxOPCCXMOY+JoCbEqEOpsClbHKbgNBgw4AfsISzuWODa47KucIQad202lUZMQ5 + iBQ9CRcSfSis6HyvCMTY5li/9a+O78FfqIGUE4FHeJpsiay2z2AMEzwBPoURkTaSjjOT25e+GY7k + ntilnVne1ORdOPMnOcd28COex55Z+C4QlOr2UIDaAinTAG/0ozwWxd8OaVJJy3mj3dd3AeD2vBMm + ycnhrM+sccqHAgMBAAECggEARelztZDg2QuhixMoUM5RDkeGWc69d14fZfgpzowQRmZTvZ/V32x2 + f7bl2yeEucjxrxF1Tk67dkZOFa9DM4BDR0qusk8zM2Th3IsFizcBkIzEJIA9dvgbXjP58VfEJSme + S5SRBOaSaoME5APPwGBWy/46XoD4x912/dTCpX9Blwl81i7EO8o3NnYhsCWeVoUJTWBzN95OchZF + ozV4pFgv0tZqTNa7VhJtWHHiKkCpdK7gA9SeVEqEeL1TADAa2ngy3BIRfTgdAct6/4N+ZlVsaIXB + 1Gnw2RaOoUbHy1PCA6ygtH5lz65p0JdWGcO5l+JNeYmOIeOdJ3QWbVI+CikPGQKBgQDv/JrTewDt + BK5KBpotFsrJeDFKOkC6A8aNeGliAEgJYvCk7zb8RtKCx7ViaYGYWJYj30oYejYEE+vFT7sgzXfE + JPAEiMw4uKeIrbX8QEIP+R25S8iRr657DkTxOvyhO2oQcC7UkZagvrVyQ17VgjtjxGWbc5bRBk5v + u1ZV9VMZuQKBgQDsL/PsLCX3YRBB5+0rpWoTKKrmFtGh31oue+d37Nd7oxBzb2uyF4Q29+zoy1on + EnHNamjjdR95NZoOjEsIIKDTV1C/bsS7be53m0mwKQfecKIXJ+7VN4UsYZXjCajHCr3NFHiIU8ct + pcKGtg7ga5cERIBtrPAi9Qzi7/o1MxUmPwKBgFuSaMWPZuAJ7DNE56mSy9gqa6xmI/KWpDmxG40Q + jGxAe5CD0thacdMDPzwJBDFMhCW1+wDyCRBvRYSpkr7GiA+pBIjGZh6ynwKxPgK9xjdwGB5vQ14L + yikcXcQqfOFM2YDiPYxQ7Ufy3St3d4VCx0SfWSIC7iZeIKnTsvLjxEzJAoGBAKcLFzou0z9N3+Cs + 9pnK6OXZ+ly3QNZ6kF6V9VRlJtXjs0vhPsr7ROBXoq/WutEtg11j6AEPIg5o8adeY+bApN40QADU + h8GD84eWRZyYuF8DTDCSZqFYHhEQh6DGgR8dIrX7x2+ryRAozxbVhloE3g7/n9Fx4Xjn1ZBfZ5fe + pBOjAoGAcw2M22BK3NWOHhJ8EC4p6aUIR96lNcCWE/ij+MWCcRdotLDSDuT1q13C+UTxDZ5PsmDs + N/bhCDRZYZoLYo0/h6v4zKBDaX05nVUTCYux0Fo2HGrj5S0bjmgyRcr8+enA3CTzCHZPWZ7ZeADb + 0Mbtt/Q4JyOCgwORgXJVQBHxxIQ= + -----END RSA PRIVATE KEY----- """ .trimIndent() internal val exampleEcPrivateKey = """ ------BEGIN EC PRIVATE KEY----- -MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCDQ+B6qEzr/M2sql4X+09X9YlYt8BKA -HX8Q7/6s4KC3qQ== ------END RSA PRIVATE KEY----- + -----BEGIN EC PRIVATE KEY----- + MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCDQ+B6qEzr/M2sql4X+09X9YlYt8BKA + HX8Q7/6s4KC3qQ== + -----END RSA PRIVATE KEY----- """ .trimIndent() } diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt index e33faf4..8f10a3a 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/frame/AboutPluginDialog.kt @@ -10,7 +10,6 @@ import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.TopGap import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBEmptyBorder -import com.intellij.util.ui.JBUI import dev.turingcomplete.intellijdevelopertoolsplugin.common.PluginInfo import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.applyDefaultTabComponentInsets import java.awt.Dimension diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/generator/BarcodeGenerator.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/generator/BarcodeGenerator.kt index 4fa1dcc..5044541 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/generator/BarcodeGenerator.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/generator/BarcodeGenerator.kt @@ -130,7 +130,7 @@ private constructor( lateinit var backgroundColorButton: JButton backgroundColorButton = button("Change") { - ColorChooserService.instance + ColorChooserService.getInstance() .showDialog( project, backgroundColorButton, @@ -149,7 +149,7 @@ private constructor( lateinit var foregroundColorButton: JButton foregroundColorButton = button("Change") { - ColorChooserService.instance + ColorChooserService.getInstance() .showDialog( project, foregroundColorButton, diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt index 33ff5e6..2a96769 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/HttpServer.kt @@ -2,8 +2,8 @@ package dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.other import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.KillableProcessHandler -import com.intellij.execution.process.ProcessAdapter import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessListener import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil import com.intellij.json.JsonLanguage @@ -50,8 +50,6 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.bind import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.bindIntTextImproved import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.common.validateLongValue import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.message.UiToolsBundle -import okhttp3.OkHttpClient -import okhttp3.Request import java.awt.Dimension import java.nio.file.Files import java.nio.file.InvalidPathException @@ -63,6 +61,8 @@ import java.time.format.DateTimeFormatter import javax.swing.JButton import javax.swing.JComponent import javax.swing.event.HyperlinkEvent +import okhttp3.OkHttpClient +import okhttp3.Request class HttpServer( private val configuration: DeveloperToolConfiguration, @@ -103,19 +103,19 @@ class HttpServer( "", CONFIGURATION, """ - { - "request": { - "method": "GET", - "urlPattern": "/" + { + "request": { + "method": "GET", + "urlPattern": "/" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" }, - "response": { - "status": 200, - "headers": { - "Content-Type": "text/plain" - }, - "body": "Hello World!" - } + "body": "Hello World!" } + } """ .trimIndent(), ) @@ -725,7 +725,7 @@ class HttpServer( processOutput: StringBuilder, ) { processHandler.addProcessListener( - object : ProcessAdapter() { + object : ProcessListener { override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { val outputChunk = event.text @@ -743,7 +743,7 @@ class HttpServer( private fun watchWireMockProcess(processHandler: KillableProcessHandler) { processHandler.addProcessListener( - object : ProcessAdapter() { + object : ProcessListener { override fun processTerminated(event: ProcessEvent) { unregisterWireMockProcess(processHandler) @@ -947,7 +947,7 @@ class HttpServer( .bind(selectedJavaExecutableMode, JavaExecutableMode.PATH) .gap(RightGap.SMALL) textFieldWithBrowseButton( - FileChooserDescriptorFactory.createSingleFileDescriptor() + FileChooserDescriptorFactory.singleFile() .withTitle( UiToolsBundle.message( "http-server.advanced-config.java-executable.path.title" diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/JsonSchemaValidator.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/JsonSchemaValidator.kt index be2ce2f..be74da0 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/JsonSchemaValidator.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/JsonSchemaValidator.kt @@ -237,37 +237,37 @@ class JsonSchemaValidator( private val EXAMPLE_SCHEMA = """ -{ - "${'$'}id": "https://example.com/person.schema.json", - "${'$'}schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Person", - "type": "object", - "properties": { - "firstName": { - "type": "string", - "description": "The person's first name." - }, - "lastName": { - "type": "string", - "description": "The person's last name." - }, - "age": { - "description": "Age in years which must be equal to or greater than zero.", - "type": "integer", - "minimum": 0 - } - } -} - """ + { + "${'$'}id": "https://example.com/person.schema.json", + "${'$'}schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Person", + "type": "object", + "properties": { + "firstName": { + "type": "string", + "description": "The person's first name." + }, + "lastName": { + "type": "string", + "description": "The person's last name." + }, + "age": { + "description": "Age in years which must be equal to or greater than zero.", + "type": "integer", + "minimum": 0 + } + } + } + """ .trimIndent() private val EXAMPLE_DATA = """ -{ - "firstName": "John", - "lastName": "Doe", - "age": 21 -} - """ + { + "firstName": "John", + "lastName": "Doe", + "age": 21 + } + """ .trimIndent() } } diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RubberDuck.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RubberDuck.kt index 87bfc4c..9d160c5 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RubberDuck.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/RubberDuck.kt @@ -26,12 +26,14 @@ class RubberDuck(parentDisposable: Disposable) : DeveloperUiTool(parentDisposabl row { cell( JBLabel( - """ - Rubber duck debugging is a problem-solving technique where a programmer explains their code line by - line to a rubber duck or any other inanimate object. The act of explaining the code helps - the programmer to identify errors and logic mistakes in their code. This technique is widely - used in software development to improve code quality and debugging efficiency. - """ + """ + | + | Rubber duck debugging is a problem-solving technique where a programmer explains their code line by + | line to a rubber duck or any other inanimate object. The act of explaining the code helps + | the programmer to identify errors and logic mistakes in their code. This technique is widely + | used in software development to improve code quality and debugging efficiency. + | + """ .trimMargin() ) ) diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt index e161ed1..2c1d430 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/other/TextStatistic.kt @@ -9,7 +9,6 @@ import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.panel import com.intellij.util.Alarm -import com.intellij.util.ui.JBUI import dev.turingcomplete.intellijdevelopertoolsplugin.common.TextStatisticUtils import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolConfiguration.PropertyType.INPUT @@ -263,12 +262,12 @@ class TextStatistic( private val TEXT_EXAMPLE = """ -Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. + Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. -Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. + Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. -A small river named Duden flows by their place and supplies it with the necessary regelialia. It is a paradisematic country, in which roasted parts of sentences fly into your mouth. - """ + A small river named Duden flows by their place and supplies it with the necessary regelialia. It is a paradisematic country, in which roasted parts of sentences fly into your mouth. + """ .trimIndent() val openTextStatisticReference = diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/JsonPathTransformer.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/JsonPathTransformer.kt index 30956d2..4832bf8 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/JsonPathTransformer.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/JsonPathTransformer.kt @@ -268,7 +268,7 @@ class JsonPathTransformer( ${'$'}..movie[-1:].directorSelects the director of the last movie in all sub-objects of the root. - """ + """ .trimIndent() ) .copyable() diff --git a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/TextFilterTransformer.kt b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/TextFilterTransformer.kt index 617e584..7f392f0 100644 --- a/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/TextFilterTransformer.kt +++ b/modules/tools/ui/src/main/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/transformer/TextFilterTransformer.kt @@ -233,7 +233,7 @@ class TextFilterTransformer( """ [info] Application started [error] Error occurred while processing request - """ + """ .trimIndent() } } diff --git a/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt b/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt index ea03c11..8780c21 100644 --- a/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt +++ b/modules/tools/ui/src/testFixtures/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/tool/ui/testfixtures/DeveloperUiToolUnderTest.kt @@ -90,20 +90,20 @@ open class DeveloperUiToolUnderTest( "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.ANCf_8p1AE4ZQs7QuqGAyyfTEgYrKSjKWkhBk5cIn1_2QVr2jEjmM-1tu7EgnyOf_fAsvdFXva8Sv05iTGzETg" id == "jwt-encoder-decoder" && property.key == "headerText" -> """ - { - "alg": "HS512", - "typ": "JWT" - } + { + "alg": "HS512", + "typ": "JWT" + } """ .trimIndent() id == "jwt-encoder-decoder" && property.key == "payloadText" -> """ - { - "sub": "1234567890", - "name": "John Doe", - "admin": true, - "iat": 1516239022 - } + { + "sub": "1234567890", + "name": "John Doe", + "admin": true, + "iat": 1516239022 + } """ .trimIndent() diff --git a/settings.gradle.kts b/settings.gradle.kts index 58ff4eb..c45cfe9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,7 +10,7 @@ val modules = mutableSetOf( Module("tools-editor", Paths.get("modules/tools/editor")), Module("tools-ui", Paths.get("modules/tools/ui")) ) -if (platform == "IC") { +if (platform == "idea") { modules.add(Module("java-dependent")) modules.add(Module("kotlin-dependent")) } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index b7bfcde..fda4914 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -24,6 +24,7 @@
  • JSON Path Parser
  • JSON Schema Validator
  • Hashing
  • +
  • HTTP Server (WireMock)
  • Archive (ZIP, TAR, JAR, 7z, ...) viewer and extractor
  • Date Time Handling (Unix Timestamp, Formatting, ...)
  • Units converters for time, data size and transfer rate
  • diff --git a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/CliCommandConverterTest.kt b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/CliCommandConverterTest.kt index a389a75..cffa8fd 100644 --- a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/CliCommandConverterTest.kt +++ b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/CliCommandConverterTest.kt @@ -37,12 +37,12 @@ class CliCommandConverterTest { assertThat(actual) .isEqualTo( """ -app \ - -foo \ - --baz \ - ---baz \ - -foo-bar "foo -baz" '-foo-bar' - """ + app \ + -foo \ + --baz \ + ---baz \ + -foo-bar "foo -baz" '-foo-bar' + """ .trimIndent() ) } @@ -59,12 +59,12 @@ app \ val actual = cliCommandConverter.doConvertToSource( """ -app \ - -foo \ - --baz \ - ---baz \ - -foo-bar "foo -baz" '-foo-bar' - """ + app \ + -foo \ + --baz \ + ---baz \ + -foo-bar "foo -baz" '-foo-bar' + """ .trimIndent() .toByteArray() ) diff --git a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/plugin/PluginXmlTest.kt b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/plugin/PluginXmlTest.kt index 7c09125..cb0cfc9 100644 --- a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/plugin/PluginXmlTest.kt +++ b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/plugin/PluginXmlTest.kt @@ -1,6 +1,8 @@ package dev.turingcomplete.intellijdevelopertoolsplugin.plugin import com.intellij.openapi.util.JDOMUtil +import java.nio.file.Files +import java.nio.file.Path import org.assertj.core.api.Assertions.assertThat import org.jdom.Element import org.junit.jupiter.api.BeforeAll @@ -102,8 +104,9 @@ class PluginXmlTest { @BeforeAll @JvmStatic fun beforeAll() { - pluginXml = - JDOMUtil.load(PluginXmlTest::class.java.getResourceAsStream("/META-INF/plugin.xml")) + Files.newInputStream(Path.of("src/main/resources/META-INF/plugin.xml")).use { + pluginXml = JDOMUtil.load(it) + } } } } From db7e767d497e63d2b5a78470770bed9e51e6c0e5 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Fri, 1 May 2026 09:22:51 +0200 Subject: [PATCH 06/11] chore: Improve plugin description and README.md --- README.md | 34 +++++++++++++------------- src/main/resources/META-INF/plugin.xml | 32 ++++++++++++------------ 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 3e6862d..6fbb51d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Plugin Logo -This plugin is a powerful and versatile set of tools designed to enhance the development experience for software engineers. With its extensive collection of features, developers can increase their productivity and simplify complex operations without leaving their coding environment. +Developer Tools brings a broad collection of everyday development utilities directly into IntelliJ-based IDEs. Encode and decode data, transform text, validate JSON, generate identifiers, inspect archives, format code and SQL, and run other common tasks without leaving the IDE. Main toolbar window: @@ -16,50 +16,50 @@ Plugin icon by [Gabriele Malaspina](https://www.svgrepo.com/svg/489187/toolbox). ## Key Features -- Encoding and Decoding: JWT (JSON Web Tokens), Base32, Base64, URL Base64, MIME Base64, URL encoding and line breaks +- Encoding and decoding: JWT (JSON Web Tokens), Base32, Base64, URL Base64, MIME Base64, URL encoding, ASCII, and line breaks - Regular Expression Matcher -- UUID, ULID, Nano ID and Password Generator +- UUID, ULID, Nano ID, and password generators - Text Sorting - Text Case Transformation - Text Diff Viewer - Text Format Conversion -- Text Escape: HTML entities, Java Strings, JSON, CSV, and XML +- Text escaping and unescaping: HTML entities, Java strings, JSON, CSV, XML, and escape sequences - Text Filter - JSON Path Parser - JSON Schema Validator -- Hashing +- Hashing and HMAC - HTTP Server (WireMock) -- Archive (ZIP, TAR, JAR, 7z, ...) viewer and extractor -- Date Time Handling (Unix Timestamp, Formatting, ...) -- Units converters for time, data size and transfer rate +- Archive viewer and extractor for ZIP, TAR, JAR, 7z, and other formats +- Date and time tools for Unix timestamps, formatting, and parsing +- Unit converters for time, data size, and transfer rate - Code Style Formatting - SQL Formatting - Color Picker -- Server certificates fetching, analyse and export +- Fetching, analyzing, and exporting server certificates - QR Code/Barcode Generator - Lorem Ipsum Generator - ASCII Art ## Integration -The main tools are currently available as a standalone dialog or tool window. Additionally, some tools are also available via the editor menu or code intentions. Some of these tools are only available if a text is selected, or the current caret position is on a Java/Kotlin string or identifier. +The main tools are available in a standalone dialog and in a tool window. Some tools are also available from the editor menu or as code intentions. Editor actions may require selected text or a caret placed on a Java/Kotlin string or identifier. -The plugin settings can be found in IntelliJ's settings/preferences under **Tools | Developer Tools**. +Plugin settings are available in IntelliJ IDEA's settings/preferences under **Tools | Developer Tools**. ### Tool Window -The tool window is available through **View | Tool Windows | Tools**. All inputs and configurations of a tool window will be stored on the project level. +The tool window is available through **View | Tool Windows | Developer Tools**. Inputs and tool configuration are stored per project. ### Dialog -The action to access the dialog is available through IntelliJ's main menu under **Tools | Developer Tools**. +The dialog is available from IntelliJ IDEA's main menu under **Tools | Developer Tools**. -To add the "Open Dialog" action to the main toolbar, we can either enable it in IntelliJ's settings/preferences under **Tools | Developer Tools**, or manually add the action via **Customize Toolbar... | Add Actions... | Developer Tools**. +To add the "Open Dialog" action to the main toolbar, enable it in IntelliJ IDEA's settings/preferences under **Tools | Developer Tools**, or add it manually via **Customize Toolbar... | Add Actions... | Developer Tools**. -All inputs and configurations of the dialog will be stored on the application level. +Dialog inputs and tool configuration are stored at the application level. ## Development -This plugin is not seen as a library. Therefore, code changes do not necessarily adhere to the semantics version rules. +This plugin is not treated as a library, so code changes do not necessarily follow semantic versioning rules. -If you want to contribute something, please follow the code style in the `.editorconfig` and sign your commits. +If you want to contribute, please follow the code style defined in `.editorconfig` and sign your commits. diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index fda4914..932697f 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -6,32 +6,32 @@ This plugin is a powerful and versatile set of tools designed to enhance the development experience for software engineers. With its extensive collection of features, developers can increase their productivity and simplify complex operations without leaving their coding environment.

    +

    Developer Tools brings a broad collection of everyday development utilities directly into IntelliJ-based IDEs. Encode and decode data, transform text, validate JSON, generate identifiers, inspect archives, format code and SQL, and run other common tasks without leaving the IDE.

    Plugin icon by Gabriele Malaspina.

    Key Features

      -
    • Encoding and Decoding: JWT (JSON Web Tokens), Base32, Base64, URL Base64, MIME Base64, URL encoding, line breaks
    • +
    • Encoding and decoding: JWT (JSON Web Tokens), Base32, Base64, URL Base64, MIME Base64, URL encoding, ASCII, and line breaks
    • Regular Expression Matcher
    • -
    • UUID, ULID, Nano ID and Password Generator
    • +
    • UUID, ULID, Nano ID, and password generators
    • Text Sorting
    • Text Case Transformation
    • Text Diff Viewer
    • Text Format Conversion
    • -
    • Text Escape: HTML entities, Java Strings, JSON, CSV, and XML
    • +
    • Text escaping and unescaping: HTML entities, Java strings, JSON, CSV, XML, and escape sequences
    • Text Filter
    • JSON Path Parser
    • JSON Schema Validator
    • -
    • Hashing
    • +
    • Hashing and HMAC
    • HTTP Server (WireMock)
    • -
    • Archive (ZIP, TAR, JAR, 7z, ...) viewer and extractor
    • -
    • Date Time Handling (Unix Timestamp, Formatting, ...)
    • -
    • Units converters for time, data size and transfer rate
    • +
    • Archive viewer and extractor for ZIP, TAR, JAR, 7z, and other formats
    • +
    • Date and time tools for Unix timestamps, formatting, and parsing
    • +
    • Unit converters for time, data size, and transfer rate
    • Code Style Formatting
    • SQL Formatting
    • Color Picker
    • -
    • Server certificates fetching, analyse and export
    • +
    • Fetching, analyzing, and exporting server certificates
    • QR Code/Barcode Generator
    • Lorem Ipsum Generator
    • ASCII Art
    • @@ -39,21 +39,21 @@

      Integration

      -

      The main tools are currently available as a standalone dialog or tool window. Additionally, some tools are also available via the editor menu or code intentions. Some of these tools are only available if a text is selected, or the current caret position is on a Java/Kotlin string or identifier.

      +

      The main tools are available in a standalone dialog and in a tool window. Some tools are also available from the editor menu or as code intentions. Editor actions may require selected text or a caret placed on a Java/Kotlin string or identifier.

      -

      The plugin settings can be found in IntelliJ's settings/preferences under Tools | Developer Tools.

      +

      Plugin settings are available in IntelliJ IDEA's settings/preferences under Tools | Developer Tools.

      Tool Window

      -

      The tool window is available under View | Tool Windows | Tools. All inputs and configurations will be stored in the project.

      +

      The tool window is available under View | Tool Windows | Developer Tools. Inputs and tool configuration are stored per project.

      -

      Dialog

      +

      Dialog

      -

      The action to access the dialog is available through IntelliJ's main menu under Tools | Developer Tools.

      +

      The dialog is available from IntelliJ IDEA's main menu under Tools | Developer Tools.

      -

      To add the "Open Dialog" action to the main toolbar, we can either enable it in IntelliJ's settings/preferences under Tools | Developer Tools, or manually add the action via Customize Toolbar... | Add Actions... | Developer Tools.

      +

      To add the "Open Dialog" action to the main toolbar, enable it in IntelliJ IDEA's settings/preferences under Tools | Developer Tools, or add it manually via Customize Toolbar... | Add Actions... | Developer Tools.

      -

      All inputs and configurations of the dialog will be stored on the application level.

      +

      Dialog inputs and tool configuration are stored at the application level.

      ]]> com.intellij.modules.platform From 926c2b9f089a23814b6e0f45e715cec524ed15ed Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Fri, 1 May 2026 09:33:33 +0200 Subject: [PATCH 07/11] chore: further improve descriptions; add AGENTS.md --- AGENTS.md | 278 +++++++++++++++++++++++++ README.md | 21 +- src/main/resources/META-INF/plugin.xml | 21 +- 3 files changed, 300 insertions(+), 20 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3bd4492 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,278 @@ +# AGENTS.md + +This repository is an IntelliJ Platform plugin written in Kotlin. It provides a "Developer Tools" tool window, standalone dialog, editor popup actions, and intention actions for common developer utilities. + +Use this file as the working map for future AI agents. It explains where the important code lives, how tools are registered, how state is persisted, and what commands to run before handing work back. + +## Project Shape + +- Root Gradle project: `intellij-developer-tools-plugin` +- Build system: Gradle Kotlin DSL with `org.jetbrains.intellij.platform` plugin. +- Language/runtime: Kotlin/JVM on Java 21. +- Minimum IDE build and platform are controlled from `gradle.properties`. +- The default `platform=idea` includes Java- and Kotlin-dependent modules. Non-IDEA platforms only include the platform/common modules. +- Plugin id is intentionally misspelled as `dev.turingcomplete.intellijdevelopertoolsplugins`; do not "fix" it. + +Important root files: + +- `build.gradle.kts`: root build, IntelliJ plugin packaging, signing, publishing, verification, common test/compiler config. +- `settings.gradle.kts`: module inclusion; conditionally includes `java-dependent` and `kotlin-dependent` when `platform == "idea"`. +- `gradle/libs.versions.toml`: dependency and plugin versions. +- `gradle.properties`: plugin metadata, platform version, bundled plugins, Kotlin stdlib opt-out. +- `src/main/resources/META-INF/plugin.xml`: main plugin descriptor and extension-point registry. +- `src/main/resources/META-INF/dev.turingcomplete.intellijdevelopertoolsplugins-withJava.xml`: optional Java plugin integrations. +- `src/main/resources/META-INF/dev.turingcomplete.intellijdevelopertoolsplugins-withKotlin.xml`: optional Kotlin plugin integrations. + +## Modules + +- `modules/common`: shared utilities, i18n bundle wrapper, plugin info, editor helpers, crypto/hash/text helpers, test fixtures. +- `modules/settings`: application and instance settings, settings UI abstractions, persistence and legacy migration for tool configurations. +- `modules/tools/editor`: editor popup actions and generic editor intentions for selected text. +- `modules/tools/ui`: the main UI tool framework, tool window/dialog implementation, all UI tools, shared Swing/UI DSL components, test fixtures. +- `modules/java-dependent`: Java PSI-specific editor actions and intentions. Only loaded through optional Java descriptor. +- `modules/kotlin-dependent`: Kotlin PSI-specific editor actions and intentions. Only loaded through optional Kotlin descriptor. +- `src/test`: root-level integration tests around plugin XML and cross-module tool behavior. + +## Plugin Entry Points + +The main descriptor is `src/main/resources/META-INF/plugin.xml`. + +It declares: + +- Required dependencies: platform, lang, json. +- Optional dependencies: `com.intellij.java` and `org.jetbrains.kotlin`, each with its own descriptor. +- Tool window: `MainToolWindowFactory`, id `Developer Tools`. +- Standalone dialog action: `OpenMainDialogAction`, added to `ToolsMenu`. +- Editor popup group: `DeveloperToolsActionGroup`, added to `EditorPopupMenu`. +- Main selected-text intention: `DataGeneratorIntentionAction`. +- Settings configurable: `GeneralSettingsConfigurable` with nested `JsonHandlingSettingsConfigurable`. +- Keymap extension: `ShowDeveloperUiToolKeymapExtension`. +- Custom extension points: + - `developerUiTool`: registers tool factories. + - `developerUiToolGroup`: registers menu groups. + - `developerToolConfigurationEnumPropertyType`: registers enum types that can be persisted in tool settings. + +The Java/Kotlin optional descriptors add language-specific intentions and editor action groups. Keep those registrations out of the main descriptor unless they do not depend on Java/Kotlin APIs. + +## UI Tool Framework + +Most new user-facing utilities should be implemented as a `DeveloperUiTool` in `modules/tools/ui`. + +Core classes: + +- `DeveloperUiTool`: base class for a tool UI. Subclasses implement `Panel.buildUi()`. It wraps UI DSL output, registers validation, supports activation/deactivation, reset, disposal, scroll wrapping, and optional `DataProvider` data. +- `DeveloperUiToolFactory`: creates tools and returns `DeveloperUiToolPresentation`. +- `DeveloperUiToolFactoryEp`: extension-point bean for `developerUiTool`; reads `id`, `implementationClass`, `groupId`, `preferredSelected`, and `internalTool` from `plugin.xml`. +- `DeveloperUiToolContext`: carries the extension id and whether vertical layout should be preferred, mainly for tool-window layout. +- `DeveloperUiToolPresentation`: titles/descriptions used in menu, grouped menu, and content panel. +- `DeveloperUiToolGroup`: extension-point bean for grouping tools in the menu tree. + +Registration flow: + +1. `plugin.xml` registers a ``. +2. `ToolsMenuTree` reads `DeveloperUiToolFactoryEp.EP_NAME`. +3. Each factory is instantiated and asked for a tool creator. +4. `DeveloperToolNode` restores or creates `DeveloperToolConfiguration` workbenches. +5. `DeveloperToolContentPanel` creates tabs, calls `DeveloperUiTool.createComponent()`, and drives `activated()`/`deactivated()`. + +If a factory returns `null` from `getDeveloperUiToolCreator`, the tool is hidden for that context. This is useful for project-dependent tools. + +## Dialog And Tool Window + +Two instances expose the same tool framework with different persistence scopes: + +- Dialog: + - `OpenMainDialogAction` opens `MainDialogService`. + - `MainDialog` uses `ContentPanelHandler` and `DeveloperToolsDialogSettings`. + - Dialog inputs/configuration are application-level. + +- Tool window: + - `MainToolWindowFactory` creates async tool-window content. + - `MainToolWindowService` stores and opens/selects tool-window content. + - `ToolWindowContentPanelHandler` uses `DeveloperToolsToolWindowSettings`. + - Tool-window inputs/configuration are project-level. + +`ContentPanelHandler` is the shared coordinator. It owns the menu tree, switches group/tool panels, caches panels depending on `generalSettings.toolWindowUiCacheUi`, and implements `openTool()`/`showTool()`. + +## Settings And Persistence + +Application settings: + +- `DeveloperToolsApplicationSettings` is an app service persisted to `developer-tools.xml`. +- It contains `GeneralSettings`, `InternalSettings`, and `JsonHandlingSettings`. +- Settings interfaces use annotations from `modules/settings/.../base`. + +Tool instance settings: + +- `DeveloperToolsInstanceSettings` is the base `PersistentStateComponent`. +- `DeveloperToolsDialogSettings` stores dialog state at app level. +- `DeveloperToolsToolWindowSettings` stores tool-window state at project level. +- Each UI tool workbench has a `DeveloperToolConfiguration`. +- Tool properties are registered by calling `configuration.register("key", defaultValue, propertyType, example)`. + +Persistence details to respect: + +- Only changed properties are persisted. +- `PropertyType.CONFIGURATION`, `INPUT`, and `SENSITIVE` are filtered by general settings. +- Sensitive values are not saved unless `saveSensitiveInputs` is enabled. +- Supported built-in persisted value types are in `DeveloperToolsInstanceSettings.builtInConfigurationPropertyTypes`. +- Enum configuration values must be registered in `plugin.xml` via ``. +- If renaming property keys or moving enum classes, update `DeveloperToolsInstanceSettingsLegacy` and the legacy test resources under `src/test/resources/.../instancesettings`. + +## Tool Implementation Patterns + +Common base classes in `modules/tools/ui/src/main/kotlin/.../tool/ui`: + +- `converter/base/Converter`: two-pane conversion base with source/target handlers, live conversion, validation, diff support, file/text handlers, and background conversion for heavy work. +- `converter/base/UndirectionalConverter`: one-way converter UI. +- `EncoderDecoder`: bidirectional converter base for encoding/decoding tools. +- `OneLineTextGenerator`: generator base for UUID/password/NanoID/etc.; handles generated value, copy, regenerate, and bulk generation. +- `MultiLineTextGenerator`: generator base for multi-line output. +- `AdvancedEditor`: shared editor component with input/output modes and diff support. +- `AsyncTaskExecutor`: debounced async UI/background task helper. +- `FileHandling`, `ErrorHolder`, validation helpers, regex helpers, and copy actions in `tool/ui/common`. + +Tool categories and representative files: + +- Converters: `tool/ui/converter`, including Base32/Base64, URL, ASCII, text format, date/time, CLI command, JWT, units. +- Transformers: `tool/ui/transformer`, including hashing/HMAC, text case, sorting/filtering, JSON Path, SQL and code formatting. +- Generators: `tool/ui/generator`, including UUID/ULID/NanoID/password/lorem/barcode. +- Other tools: `tool/ui/other`, including regex matcher, JSON schema validator, unarchiver, color picker, server certificates, HTTP server, notes, text diff/statistics, cron, ASCII art. + +When adding a new UI tool: + +1. Choose the closest base class instead of starting from `DeveloperUiTool` directly. +2. Put it in the matching package under `modules/tools/ui`. +3. Add a nested `Factory : DeveloperUiToolFactory`. +4. Register the factory in `plugin.xml` with a stable `id`. +5. Add enum property type registrations for any persisted enum defaults. +6. Use `configuration.register(...)` for all persisted controls. Pick stable keys. +7. Add bundle entries if the surrounding package uses message bundles. +8. Add or update tests when persistence, plugin XML registration, or conversion behavior changes. + +## Editor Actions And Intentions + +Generic selected-text actions live in `modules/tools/editor`. + +- `DeveloperToolsActionGroup` is the root editor popup group. +- `EncodeDecodeActionGroup`, `EscapeUnescapeActionGroup`, `TextCaseConverterActionGroup`, `DataGeneratorActionGroup`, and `EditorTextStatisticAction` operate mostly on selected text. +- Shared operation lists are in `EncodersDecoders`, `EscapersUnescapers`, and `DataGenerators`. +- Editor mutations should go through `EditorUtils.executeWriteCommand`. +- Error dialogs are shown via IntelliJ APIs and failures are logged. + +Generic intentions live in `modules/tools/editor/src/main/.../intention`. + +Java/Kotlin language-specific variants live in: + +- `modules/java-dependent/.../PsiJavaUtils.kt` +- `modules/java-dependent/.../tool/editor/action` +- `modules/java-dependent/.../tool/editor/intention` +- `modules/kotlin-dependent/.../PsiKotlinUtils.kt` +- `modules/kotlin-dependent/.../tool/editor/action` +- `modules/kotlin-dependent/.../tool/editor/intention` + +Those modules are optional and must stay behind their optional plugin descriptors. + +## Testing + +Common commands: + +```bash +./gradlew test +./gradlew check +./gradlew verifyPlugin +./gradlew spotlessApply +``` + +CI runs: + +```bash +./gradlew check --stacktrace +./gradlew verifyPlugin --stacktrace +``` + +Focused examples: + +```bash +./gradlew :tools-ui:test +./gradlew :settings:test +./gradlew test --tests '*PluginXmlTest' +./gradlew test --tests '*DeveloperToolsInstanceSettingsTest' +``` + +Test infrastructure: + +- `IdeaTest` sets up IntelliJ test application/project fixtures and Bouncy Castle. +- `PluginXmlTest` validates descriptor class/file references. +- `DeveloperUiToolsInstances` instantiates all registered UI tools from the extension point for integration tests. +- `DeveloperUiToolUnderTest` randomizes and resets registered tool properties. +- Intention description tests validate bundled intention descriptions/templates. + +Some tests need IntelliJ test infrastructure and can be slow. On Linux/CI, run under Xvfb as the workflow does. + +## Style And Conventions + +- Kotlin formatting is ktfmt Google style through Spotless. +- `.editorconfig` uses 2-space Kotlin indentation and 100 character max line length. +- The code often uses section comments like `// -- Properties --`; keep them when editing nearby code. +- Prefer IntelliJ Platform APIs and the local helper classes over new abstractions. +- Use IntelliJ UI DSL builder patterns already present in neighboring tools. +- Register disposables with the passed `parentDisposable`. +- Keep long or blocking work off the EDT. Existing code uses pooled threads, `Task.Backgroundable`, `AsyncTaskExecutor`, and `Alarm`. +- Many UI components rely on `ValueProperty` or IntelliJ observable properties; bind controls rather than manually syncing when possible. +- Do not change plugin ids, extension ids, or persisted property keys without migration/test updates. + +## External Processes And Downloads + +The HTTP Server tool downloads and runs WireMock standalone: + +- Implementation: `HttpServer.kt`. +- Process tracking: `ExternalSystemProcessRegistry.kt`. +- Download URL/version constants are at the bottom of `HttpServer.kt`. +- The tool supports built-in and custom server modes and registers/stops process handlers. + +Be careful with lifecycle changes here: processes must be unregistered and stopped on disposal. + +## Release And Verification Notes + +- `publishPlugin` depends on `check` and requires `platform == "idea"`. +- Signing reads certificate/private key from `~/.jetbrains` and a Gradle property password. +- Marketplace token is read from Gradle properties. +- `buildSearchableOptions` is disabled. +- Plugin verification uses recommended IDEs plus additional IDEs from `pluginVerificationAdditionalIdes`. +- Changelog rendering comes from `CHANGELOG.md`; `tools-ui` also generates a bundled `changelog.html`. + +## Agent Workflow + +Before editing: + +- Run `git status --short` and do not overwrite unrelated user changes. +- Use `rg`/`rg --files` for navigation. +- Read the closest existing implementation before adding a new pattern. + +For UI tools: + +- Start from the nearest tool in the same package. +- Wire configuration through `DeveloperToolConfiguration`. +- Register tool and enum property types in `plugin.xml`. +- Run at least `./gradlew :tools-ui:test` or the smallest relevant module test, plus `PluginXmlTest` if descriptors changed. + +For settings persistence: + +- Check `DeveloperToolsInstanceSettings`, `DeveloperToolConfiguration`, and legacy migration tests. +- Add enum registrations and migration entries as needed. +- Run `DeveloperToolsInstanceSettingsTest`. + +For editor actions/intentions: + +- Keep generic selected-text logic in `tools-editor`. +- Keep Java/Kotlin PSI logic in optional modules. +- Add/update intention descriptions under `resources/intentionDescriptions`. +- Run the relevant intention description tests. + +Before final response: + +- Run the narrowest meaningful Gradle tests. +- If descriptor or broad registration changed, run `PluginXmlTest`. +- If formatting changed, run `./gradlew spotlessApply` or `./gradlew spotlessCheck`. +- Mention any tests that could not be run. diff --git a/README.md b/README.md index 6fbb51d..2664470 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Plugin Logo -Developer Tools brings a broad collection of everyday development utilities directly into IntelliJ-based IDEs. Encode and decode data, transform text, validate JSON, generate identifiers, inspect archives, format code and SQL, and run other common tasks without leaving the IDE. +Developer Tools brings a practical toolbox of everyday development utilities directly into IntelliJ-based IDEs. It keeps common tasks such as encoding data, transforming text, validating JSON, generating identifiers, inspecting archives, formatting code and SQL, and checking certificates inside the IDE, so you do not need to switch to separate web tools or command-line snippets. Main toolbar window: @@ -16,14 +16,16 @@ Plugin icon by [Gabriele Malaspina](https://www.svgrepo.com/svg/489187/toolbox). ## Key Features -- Encoding and decoding: JWT (JSON Web Tokens), Base32, Base64, URL Base64, MIME Base64, URL encoding, ASCII, and line breaks +- JWT Encoder/Decoder +- Base32, Base64, URL Base64, MIME Base64, URL, and ASCII Encoder/Decoder +- Text escaping and unescaping for HTML entities, Java strings, JSON, CSV, XML, and escape sequences - Regular Expression Matcher -- UUID, ULID, Nano ID, and password generators +- UUID, ULID, Nano ID, password, QR code/barcode, Lorem Ipsum, and ASCII art generators - Text Sorting - Text Case Transformation - Text Diff Viewer - Text Format Conversion -- Text escaping and unescaping: HTML entities, Java strings, JSON, CSV, XML, and escape sequences +- Text Statistic - Text Filter - JSON Path Parser - JSON Schema Validator @@ -34,21 +36,20 @@ Plugin icon by [Gabriele Malaspina](https://www.svgrepo.com/svg/489187/toolbox). - Unit converters for time, data size, and transfer rate - Code Style Formatting - SQL Formatting +- CLI Command Conversion - Color Picker - Fetching, analyzing, and exporting server certificates -- QR Code/Barcode Generator -- Lorem Ipsum Generator -- ASCII Art +- Notes ## Integration -The main tools are available in a standalone dialog and in a tool window. Some tools are also available from the editor menu or as code intentions. Editor actions may require selected text or a caret placed on a Java/Kotlin string or identifier. +The full toolbox is available in both a persistent tool window and a standalone dialog. Tools can have multiple named workbenches, so you can keep separate inputs and configurations for different tasks. Frequently used text operations are also available from the editor popup menu and as intentions; depending on the action, they work on selected text or on the Java/Kotlin string or identifier at the caret. Plugin settings are available in IntelliJ IDEA's settings/preferences under **Tools | Developer Tools**. ### Tool Window -The tool window is available through **View | Tool Windows | Developer Tools**. Inputs and tool configuration are stored per project. +The tool window is available through **View | Tool Windows | Developer Tools**. Inputs, selected tools, expanded menu groups, and tool configuration are stored per project. ### Dialog @@ -56,7 +57,7 @@ The dialog is available from IntelliJ IDEA's main menu under **Tools | Developer To add the "Open Dialog" action to the main toolbar, enable it in IntelliJ IDEA's settings/preferences under **Tools | Developer Tools**, or add it manually via **Customize Toolbar... | Add Actions... | Developer Tools**. -Dialog inputs and tool configuration are stored at the application level. +Dialog inputs, selected tools, expanded menu groups, and tool configuration are stored at the application level. ## Development diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 932697f..4d8d61b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -6,20 +6,22 @@ Developer Tools brings a broad collection of everyday development utilities directly into IntelliJ-based IDEs. Encode and decode data, transform text, validate JSON, generate identifiers, inspect archives, format code and SQL, and run other common tasks without leaving the IDE.

      +

      Developer Tools brings a practical toolbox of everyday development utilities directly into IntelliJ-based IDEs. It keeps common tasks such as encoding data, transforming text, validating JSON, generating identifiers, inspecting archives, formatting code and SQL, and checking certificates inside the IDE, so you do not need to switch to separate web tools or command-line snippets.

      Plugin icon by Gabriele Malaspina.

      Key Features

        -
      • Encoding and decoding: JWT (JSON Web Tokens), Base32, Base64, URL Base64, MIME Base64, URL encoding, ASCII, and line breaks
      • +
      • JWT Encoder/Decoder
      • +
      • Base32, Base64, URL Base64, MIME Base64, URL, and ASCII Encoder/Decoder
      • +
      • Text escaping and unescaping for HTML entities, Java strings, JSON, CSV, XML, and escape sequences
      • Regular Expression Matcher
      • -
      • UUID, ULID, Nano ID, and password generators
      • +
      • UUID, ULID, Nano ID, password, QR code/barcode, Lorem Ipsum, and ASCII art generators
      • Text Sorting
      • Text Case Transformation
      • Text Diff Viewer
      • Text Format Conversion
      • -
      • Text escaping and unescaping: HTML entities, Java strings, JSON, CSV, XML, and escape sequences
      • +
      • Text Statistic
      • Text Filter
      • JSON Path Parser
      • JSON Schema Validator
      • @@ -30,22 +32,21 @@
      • Unit converters for time, data size, and transfer rate
      • Code Style Formatting
      • SQL Formatting
      • +
      • CLI Command Conversion
      • Color Picker
      • Fetching, analyzing, and exporting server certificates
      • -
      • QR Code/Barcode Generator
      • -
      • Lorem Ipsum Generator
      • -
      • ASCII Art
      • +
      • Notes

      Integration

      -

      The main tools are available in a standalone dialog and in a tool window. Some tools are also available from the editor menu or as code intentions. Editor actions may require selected text or a caret placed on a Java/Kotlin string or identifier.

      +

      The full toolbox is available in both a persistent tool window and a standalone dialog. Tools can have multiple named workbenches, so you can keep separate inputs and configurations for different tasks. Frequently used text operations are also available from the editor popup menu and as intentions; depending on the action, they work on selected text or on the Java/Kotlin string or identifier at the caret.

      Plugin settings are available in IntelliJ IDEA's settings/preferences under Tools | Developer Tools.

      Tool Window

      -

      The tool window is available under View | Tool Windows | Developer Tools. Inputs and tool configuration are stored per project.

      +

      The tool window is available under View | Tool Windows | Developer Tools. Inputs, selected tools, expanded menu groups, and tool configuration are stored per project.

      Dialog

      @@ -53,7 +54,7 @@

      To add the "Open Dialog" action to the main toolbar, enable it in IntelliJ IDEA's settings/preferences under Tools | Developer Tools, or add it manually via Customize Toolbar... | Add Actions... | Developer Tools.

      -

      Dialog inputs and tool configuration are stored at the application level.

      +

      Dialog inputs, selected tools, expanded menu groups, and tool configuration are stored at the application level.

      ]]>
      com.intellij.modules.platform From 06ede90a563af864ffe5fea95c1c9783c2d34981 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Fri, 1 May 2026 18:15:27 +0200 Subject: [PATCH 08/11] release: Bump version to 8.0.0 --- CHANGELOG.md | 20 +- gradle.properties | 2 +- .../DeveloperToolsInstanceSettingsTest.kt | 33 +- .../expected-configuration-properties.csv | 576 ++++++++++++++ .../instance-settings-persisted-state.xml | 740 ++++++++++++++++++ .../removed-configuration-properties.csv | 1 + .../renamed-configuration-properties.csv | 1 + 7 files changed, 1348 insertions(+), 25 deletions(-) create mode 100644 src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/expected-configuration-properties.csv create mode 100644 src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/instance-settings-persisted-state.xml create mode 100644 src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/removed-configuration-properties.csv create mode 100644 src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/renamed-configuration-properties.csv diff --git a/CHANGELOG.md b/CHANGELOG.md index b93f248..3fc9c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,25 @@ ### Added -- Added new tool "HTTP Server" to start and manage an easily configurable local HTTP server. -- Added support for algo=none to the JWT tool. -- Added support for validating JWTs via public keys and JWKS. -- Overhauled the JWT tool UI. - ### Changed -- Raise minimum IntelliJ version to 2026.1 - ### Removed ### Fixed +## 8.0.0 - 2026-05-01 + +### Added + +- Added a new HTTP Server tool for starting and managing a configurable local HTTP server based on WireMock. +- Added support for alg=none in the JWT tool. +- Added support for validating JWTs using public keys and JWKS. +- Overhauled the JWT tool UI. + +### Changed + +- Raise minimum IntelliJ version to 2026.1 + ## 7.1.0 - 2025-05-18 ### Added diff --git a/gradle.properties b/gradle.properties index 280e476..0fdcba2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # The typo in the ID is frustrating, but we will never be able to change it :/ pluginId=dev.turingcomplete.intellijdevelopertoolsplugins pluginGroup=dev.turingcomplete -pluginVersion=7.1.0 +pluginVersion=8.0.0 pluginName=Developer Tools pluginSinceBuild=261 platform=idea diff --git a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt index 03fa62d..588294a 100644 --- a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt +++ b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt @@ -16,22 +16,6 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolsIn import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolsInstanceSettingsLegacy import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.testfixtures.DeveloperUiToolUnderTest import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.testfixtures.DeveloperUiToolsInstances.createDeveloperUiToolsUnderTest -import java.math.BigDecimal -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.nio.file.StandardOpenOption -import java.util.Locale -import java.util.SortedMap -import kotlin.io.path.bufferedReader -import kotlin.io.path.createDirectories -import kotlin.io.path.exists -import kotlin.io.path.isDirectory -import kotlin.io.path.name -import kotlin.io.path.writeText -import kotlin.io.path.writer -import kotlin.reflect.KClass -import kotlin.streams.asSequence import org.apache.commons.csv.CSVFormat import org.apache.commons.csv.CSVParser import org.apache.commons.csv.CSVPrinter @@ -46,6 +30,21 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestFactory import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import java.math.BigDecimal +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardOpenOption +import java.util.* +import kotlin.io.path.bufferedReader +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.name +import kotlin.io.path.writeText +import kotlin.io.path.writer +import kotlin.reflect.KClass +import kotlin.streams.asSequence class DeveloperToolsInstanceSettingsTest : IdeaTest() { // -- Properties ---------------------------------------------------------- // @@ -112,7 +111,7 @@ class DeveloperToolsInstanceSettingsTest : IdeaTest() { testNodes.add( dynamicTest("No persisted properties after they have been reset") { // Load all example values - DeveloperToolsApplicationSettings.Companion.generalSettings.loadExamples.set(true) + DeveloperToolsApplicationSettings.generalSettings.loadExamples.set(true) developerUiToolsUnderTest.forEach { it.resetConfiguration(loadExamples = true) } // Expect: No configurations have persisted because there are no property changes diff --git a/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/expected-configuration-properties.csv b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/expected-configuration-properties.csv new file mode 100644 index 0000000..905776f --- /dev/null +++ b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/expected-configuration-properties.csv @@ -0,0 +1,576 @@ +developerToolId,propertyKey,propertyType,propertyValueTypeName,propertyValue +ascii-art,asciiArtOutput-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +ascii-art,asciiArtOutput-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +ascii-art,asciiArtOutput-editor-softWraps,CONFIGURATION,kotlin.Boolean,true +ascii-art,selectedFontFileName,CONFIGURATION,kotlin.String,slant.flf +ascii-art,textInput,INPUT,kotlin.String,6rSbxqPSLGQCiUBQ5GEh +ascii-encoder-decoder,liveConversion,CONFIGURATION,kotlin.Boolean,false +ascii-encoder-decoder,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +ascii-encoder-decoder,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +ascii-encoder-decoder,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +ascii-encoder-decoder,sourceFile,INPUT,kotlin.String,JWsL4hqqGvxNP5a29uNM +ascii-encoder-decoder,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +ascii-encoder-decoder,sourceText,INPUT,kotlin.String,SwdQfNWz8mPVK8ru4rEt +ascii-encoder-decoder,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +ascii-encoder-decoder,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +ascii-encoder-decoder,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +ascii-encoder-decoder,targetFile,INPUT,kotlin.String,ITTksDJmdiNJh6MtTVZu +ascii-encoder-decoder,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +ascii-encoder-decoder,targetText,INPUT,kotlin.String,SwdQfNWz8mPVK8ru4rEt +base32-encoder-decoder,liveConversion,CONFIGURATION,kotlin.Boolean,false +base32-encoder-decoder,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +base32-encoder-decoder,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +base32-encoder-decoder,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +base32-encoder-decoder,sourceFile,INPUT,kotlin.String,yGLu7VYYOKvFB3HSJZV2 +base32-encoder-decoder,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +base32-encoder-decoder,sourceText,INPUT,kotlin.String,pBqfIDcd5QSSbVcp8OvF +base32-encoder-decoder,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +base32-encoder-decoder,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +base32-encoder-decoder,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +base32-encoder-decoder,targetFile,INPUT,kotlin.String,kj6TiBzhFqjXwCfsMO4X +base32-encoder-decoder,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +base32-encoder-decoder,targetText,INPUT,kotlin.String,OBBHCZSJIRRWINKRKNJWEVTDOA4E65SG +base64-encoder-decoder,liveConversion,CONFIGURATION,kotlin.Boolean,false +base64-encoder-decoder,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +base64-encoder-decoder,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +base64-encoder-decoder,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +base64-encoder-decoder,sourceFile,INPUT,kotlin.String,r0PacuiHJB3ItWu3zJZS +base64-encoder-decoder,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +base64-encoder-decoder,sourceText,INPUT,kotlin.String,pjZodxXAhhKka5IW6hba +base64-encoder-decoder,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +base64-encoder-decoder,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +base64-encoder-decoder,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +base64-encoder-decoder,targetFile,INPUT,kotlin.String,njdnx2EyaEocSmI3U8Ap +base64-encoder-decoder,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +base64-encoder-decoder,targetText,INPUT,kotlin.String,cGpab2R4WEFoaEtrYTVJVzZoYmE= +certificates-download,allowInsecureConnection,CONFIGURATION,kotlin.Boolean,true +certificates-download,followRedirects,CONFIGURATION,kotlin.Boolean,false +certificates-download,url,INPUT,kotlin.String,syVelfewljV0IlQx965U +cli-command-converter,lineBreakDelimiter,CONFIGURATION,kotlin.String,faicM6AcM0P2HJxW2OLI +cli-command-converter,liveConversion,CONFIGURATION,kotlin.Boolean,false +cli-command-converter,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +cli-command-converter,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +cli-command-converter,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +cli-command-converter,sourceFile,INPUT,kotlin.String,wSWNq2ApnpcIPULUKjFI +cli-command-converter,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +cli-command-converter,sourceText,INPUT,kotlin.String,j20IwAM0Rdku02TIwFE0 +cli-command-converter,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +cli-command-converter,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +cli-command-converter,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +cli-command-converter,targetFile,INPUT,kotlin.String,bJkzW1HJO6XUmc30RCNg +cli-command-converter,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +cli-command-converter,targetText,INPUT,kotlin.String,j20IwAM0Rdku02TIwFE0 +code-style-formatting,languageId,CONFIGURATION,kotlin.String,XML +code-style-formatting,liveTransformation,CONFIGURATION,kotlin.Boolean,false +code-style-formatting,result-output-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +code-style-formatting,result-output-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +code-style-formatting,result-output-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +code-style-formatting,source-input-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +code-style-formatting,source-input-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +code-style-formatting,source-input-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +code-style-formatting,sourceText,INPUT,kotlin.String, +color-picker,decimalPlaces,CONFIGURATION,kotlin.Int,3 +color-picker,selectedColor,INPUT,com.intellij.ui.JBColor,-16777032 +cron-expression,cronExpression-CRON4J,INPUT,kotlin.String,bS9fBf39LCeQDCb4FjyM +cron-expression,cronExpression-QUARTZ,INPUT,kotlin.String,CTZsDR7k0OQZiVabRg4n +cron-expression,cronExpression-SPRING,INPUT,kotlin.String,Utq3ZUkgIQCE6a2pWb4G +cron-expression,cronExpression-SPRING53,INPUT,kotlin.String,Evmtw7yec8Og0heWGFtG +cron-expression,cronExpression-UNIX,INPUT,kotlin.String,GfszMXwUX0ZVnG8xdl4r +cron-expression,selectedCronType,CONFIGURATION,CronExpression-CronType,SPRING +csv-text-escape,liveConversion,CONFIGURATION,kotlin.Boolean,false +csv-text-escape,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +csv-text-escape,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +csv-text-escape,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +csv-text-escape,sourceFile,INPUT,kotlin.String,f8jEBcDh4ep0iCLO6r1B +csv-text-escape,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +csv-text-escape,sourceText,INPUT,kotlin.String,9zVPh0W2fhjgsd7IXWph +csv-text-escape,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +csv-text-escape,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +csv-text-escape,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +csv-text-escape,targetFile,INPUT,kotlin.String,3wJ75ka2g94w9NS0hD7P +csv-text-escape,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +csv-text-escape,targetText,INPUT,kotlin.String,9zVPh0W2fhjgsd7IXWph +date-time-converter,formattedIndividual,CONFIGURATION,kotlin.Boolean,true +date-time-converter,formattedIndividualFormat,CONFIGURATION,kotlin.String,ID2U0yDwppBDMz4e9QTn +date-time-converter,formattedLocale,CONFIGURATION,dev.turingcomplete.intellijdevelopertoolsplugin.common.LocaleContainer,ru-MD +date-time-converter,formattedStandardFormat,CONFIGURATION,DatetimeConverter-StandardFormat,ISO_8601_DATE +date-time-converter,formattedStandardFormatAddOffset,CONFIGURATION,kotlin.Boolean,false +date-time-converter,formattedStandardFormatAddTimeZone,CONFIGURATION,kotlin.Boolean,true +date-time-converter,timeZoneId,CONFIGURATION,kotlin.String,America/Glace_Bay +escape-sequence-escaper-unescaper,backslashsEncodingEnabled,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,doubleQuotesEncodingEnabled,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,lineBreaksEncodingEnabled,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,lineBreaksEscapeSequence,CONFIGURATION,EscapeSequencesEncoderDecoder-LineBreak,LF +escape-sequence-escaper-unescaper,liveConversion,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,singleQuotesEncodingEnabled,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +escape-sequence-escaper-unescaper,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +escape-sequence-escaper-unescaper,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,sourceFile,INPUT,kotlin.String,DxhUz8PTRTFIyx3sgstP +escape-sequence-escaper-unescaper,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +escape-sequence-escaper-unescaper,sourceText,INPUT,kotlin.String,H99VI1CoiVpzf8PRN7lJ +escape-sequence-escaper-unescaper,tabsEncodingEnabled,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +escape-sequence-escaper-unescaper,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +escape-sequence-escaper-unescaper,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +escape-sequence-escaper-unescaper,targetFile,INPUT,kotlin.String,R3XmAbuMhCDHUXV7R5yN +escape-sequence-escaper-unescaper,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +escape-sequence-escaper-unescaper,targetText,INPUT,kotlin.String,H99VI1CoiVpzf8PRN7lJ +hashing-transformer,algorithm,CONFIGURATION,kotlin.String,WHIRLPOOL +hashing-transformer,liveConversion,CONFIGURATION,kotlin.Boolean,false +hashing-transformer,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +hashing-transformer,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +hashing-transformer,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +hashing-transformer,sourceFile,INPUT,kotlin.String,Rugj0GbNFZaYJNiIk7NG +hashing-transformer,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +hashing-transformer,sourceText,INPUT,kotlin.String,FCucrlEzDJHZ4qAanCx4 +hashing-transformer,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +hashing-transformer,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +hashing-transformer,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +hashing-transformer,targetFile,INPUT,kotlin.String,AoiB357yvF01FOzPp8Fc +hashing-transformer,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +hmac-transformer,algorithm,CONFIGURATION,kotlin.String,HMACSKEIN-512-512 +hmac-transformer,liveConversion,CONFIGURATION,kotlin.Boolean,false +hmac-transformer,secretKey,SENSITIVE,kotlin.String,jSxiPusODqEV23mdrzSy +hmac-transformer,secretKeyEncodingMode,CONFIGURATION,HmacTransformer-SecretKeyEncodingMode,BASE32 +hmac-transformer,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +hmac-transformer,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +hmac-transformer,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +hmac-transformer,sourceFile,INPUT,kotlin.String,qdJRSztgubN2fN9BcGBn +hmac-transformer,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +hmac-transformer,sourceText,INPUT,kotlin.String,pSEjTAOnBxNu6j20s7eT +hmac-transformer,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +hmac-transformer,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +hmac-transformer,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +hmac-transformer,targetFile,INPUT,kotlin.String,wJWf1KvrgyzE9QLzgW7P +hmac-transformer,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +html-entities-escape,liveConversion,CONFIGURATION,kotlin.Boolean,false +html-entities-escape,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +html-entities-escape,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +html-entities-escape,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +html-entities-escape,sourceFile,INPUT,kotlin.String,1IyFnYMIaUE2CsMYWc2M +html-entities-escape,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +html-entities-escape,sourceText,INPUT,kotlin.String,UBJzxCv7T9iqBQ35FEu2 +html-entities-escape,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +html-entities-escape,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +html-entities-escape,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +html-entities-escape,targetFile,INPUT,kotlin.String,M12PmWKkQbojKcy4SGEJ +html-entities-escape,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +html-entities-escape,targetText,INPUT,kotlin.String,UBJzxCv7T9iqBQ35FEu2 +http-server,advancedCommandLineOptions,CONFIGURATION,kotlin.String,6dWom6bJPtQEZjE91RAB +http-server,builtInServerMapping,CONFIGURATION,kotlin.String,tm1D2szH1l5sJ7QN3js6 +http-server,builtInServerMapping-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +http-server,builtInServerMapping-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +http-server,builtInServerMapping-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +http-server,customRootDirectory,CONFIGURATION,kotlin.String,rviJkIoHSFatAGZukJJb +http-server,httpServerOutput-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +http-server,httpServerOutput-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +http-server,httpServerOutput-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +http-server,javaExecutableMode,CONFIGURATION,HttpServer-JavaExecutableMode,PATH +http-server,javaExecutablePath,CONFIGURATION,kotlin.String,d68HgfBsjL8sBZ5HGTr5 +http-server,printAllNetworkTraffic,CONFIGURATION,kotlin.Boolean,true +http-server,serverMode,CONFIGURATION,HttpServer-ServerMode,CUSTOM_DIRECTORY +http-server,serverPort,CONFIGURATION,kotlin.Int,8090 +http-server,verboseLogging,CONFIGURATION,kotlin.Boolean,true +java-text-escape,liveConversion,CONFIGURATION,kotlin.Boolean,false +java-text-escape,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +java-text-escape,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +java-text-escape,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +java-text-escape,sourceFile,INPUT,kotlin.String,tU0hTkuIM3mTDheXkawo +java-text-escape,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +java-text-escape,sourceText,INPUT,kotlin.String,C1iJVwDasaGiyEQHTeCk +java-text-escape,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +java-text-escape,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +java-text-escape,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +java-text-escape,targetFile,INPUT,kotlin.String,392cR8bdSWIiZki4BKX8 +java-text-escape,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +java-text-escape,targetText,INPUT,kotlin.String,C1iJVwDasaGiyEQHTeCk +json-path,contentText,INPUT,kotlin.String,SMJDxDYag4PzXxzalPXn +json-path,formatResult,CONFIGURATION,kotlin.Boolean,false +json-path,liveTransformation,CONFIGURATION,kotlin.Boolean,false +json-path,result-output-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +json-path,result-output-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +json-path,result-output-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +json-path,source-input-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +json-path,source-input-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +json-path,source-input-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +json-path,sourceText,INPUT,kotlin.String,GaHLbOuViOL37LugKkG6 +json-schema-validator,data-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +json-schema-validator,data-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +json-schema-validator,data-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +json-schema-validator,dataText,INPUT,kotlin.String,y2AdfhVENP25zFvjFiYJ +json-schema-validator,liveValidation,CONFIGURATION,kotlin.Boolean,false +json-schema-validator,schema-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +json-schema-validator,schema-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +json-schema-validator,schema-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +json-schema-validator,schemaText,INPUT,kotlin.String,11ex6xb5ItSJ3qppnARn +json-text-escape,liveConversion,CONFIGURATION,kotlin.Boolean,false +json-text-escape,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +json-text-escape,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +json-text-escape,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +json-text-escape,sourceFile,INPUT,kotlin.String,8v8oblcHaQD7DwuwwURh +json-text-escape,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +json-text-escape,sourceText,INPUT,kotlin.String,CuBVr4xnu7TPDoMvVe45 +json-text-escape,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +json-text-escape,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +json-text-escape,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +json-text-escape,targetFile,INPUT,kotlin.String,T7JwGZu06laB53SmMzmu +json-text-escape,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +json-text-escape,targetText,INPUT,kotlin.String,CuBVr4xnu7TPDoMvVe45 +jwt-encoder-decoder,algorithm,CONFIGURATION,JwtEncoderDecoder-SignatureAlgorithm,HMAC512 +jwt-encoder-decoder,encoded-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +jwt-encoder-decoder,encoded-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +jwt-encoder-decoder,encoded-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +jwt-encoder-decoder,encodedText,INPUT,kotlin.String,eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.ANCf_8p1AE4ZQs7QuqGAyyfTEgYrKSjKWkhBk5cIn1_2QVr2jEjmM-1tu7EgnyOf_fAsvdFXva8Sv05iTGzETg +jwt-encoder-decoder,header-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +jwt-encoder-decoder,header-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +jwt-encoder-decoder,header-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +jwt-encoder-decoder,headerText,INPUT,kotlin.String,"{ + ""alg"": ""HS512"", + ""typ"": ""JWT"" +}" +jwt-encoder-decoder,liveConversion,CONFIGURATION,kotlin.Boolean,false +jwt-encoder-decoder,payload-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +jwt-encoder-decoder,payload-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +jwt-encoder-decoder,payload-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +jwt-encoder-decoder,payloadText,INPUT,kotlin.String,"{ + ""sub"": ""1234567890"", + ""name"": ""John Doe"", + ""admin"": true, + ""iat"": 1516239022 +}" +jwt-encoder-decoder,privateKey,SENSITIVE,kotlin.String,OCr3Bbtm1Jcy7nrAblDw +jwt-encoder-decoder,secret,SENSITIVE,kotlin.String,9PbJXETQxZ4Hq5lMCxZG +jwt-encoder-decoder,secretKeyEncodingMode,CONFIGURATION,JwtEncoderDecoder-SecretKeyEncodingMode,BASE32 +jwt-encoder-decoder,signingKeyValidation,CONFIGURATION,kotlin.Boolean,true +jwt-encoder-decoder,validationJwksJson,INPUT,kotlin.String,S1cuwNjmPFFyraxt4gKc +jwt-encoder-decoder,validationJwksUrl,INPUT,kotlin.String,0NnP2c5J9WjZbkK27w74 +jwt-encoder-decoder,validationKeySource,CONFIGURATION,kotlin.String,WO23wqyzvN50xMc8EXuU +jwt-encoder-decoder,validationPublicKey,SENSITIVE,kotlin.String,Npe9Xhd99avg1uV1nCMD +jwt-encoder-decoder,validationSecret,SENSITIVE,kotlin.String,NdumcfINsFBzml5sn38j +jwt-encoder-decoder,validationSecretKeyEncodingMode,CONFIGURATION,JwtEncoderDecoder-SecretKeyEncodingMode,BASE32 +jwt-encoder-decoder,validationStrictKeyValidation,CONFIGURATION,kotlin.Boolean,true +lorem-ipsum-generator,generated-text-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +lorem-ipsum-generator,generated-text-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +lorem-ipsum-generator,generated-text-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +lorem-ipsum-generator,generatedTextKind,CONFIGURATION,LoremIpsumGenerator-TextMode,WORDS +lorem-ipsum-generator,maxWordsInBullet,CONFIGURATION,kotlin.Int,31 +lorem-ipsum-generator,maxWordsInParagraph,CONFIGURATION,kotlin.Int,101 +lorem-ipsum-generator,minWordsInBullet,CONFIGURATION,kotlin.Int,11 +lorem-ipsum-generator,minWordsInParagraph,CONFIGURATION,kotlin.Int,21 +lorem-ipsum-generator,numberOfValues,CONFIGURATION,kotlin.Int,10 +lorem-ipsum-generator,startWithLoremIpsum,CONFIGURATION,kotlin.Boolean,false +mime-base64-encoder-decoder,liveConversion,CONFIGURATION,kotlin.Boolean,false +mime-base64-encoder-decoder,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +mime-base64-encoder-decoder,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +mime-base64-encoder-decoder,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +mime-base64-encoder-decoder,sourceFile,INPUT,kotlin.String,uABajngVv67d8B6XWyeh +mime-base64-encoder-decoder,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +mime-base64-encoder-decoder,sourceText,INPUT,kotlin.String,W25KziwQFDq9eJNgNcxL +mime-base64-encoder-decoder,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +mime-base64-encoder-decoder,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +mime-base64-encoder-decoder,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +mime-base64-encoder-decoder,targetFile,INPUT,kotlin.String,nPAXHLbqdU88eBVAGpsW +mime-base64-encoder-decoder,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +mime-base64-encoder-decoder,targetText,INPUT,kotlin.String,VzI1S3ppd1FGRHE5ZUpOZ05jeEw= +nano-id-generator,bulk-generation-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +nano-id-generator,bulk-generation-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +nano-id-generator,bulk-generation-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +notes,content-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +notes,content-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +notes,content-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +notes,test,INPUT,kotlin.String,XB083PeLaiA5vFoBPy1M +password-generator,addDigits,CONFIGURATION,kotlin.Boolean,false +password-generator,addSymbols,CONFIGURATION,kotlin.Boolean,false +password-generator,bulk-generation-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +password-generator,bulk-generation-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +password-generator,bulk-generation-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +password-generator,length,CONFIGURATION,kotlin.Int,31 +password-generator,lettersMode,CONFIGURATION,PasswordGenerator-LettersMode,ASCII_ALPHABET_ONLY_LOWERCASE +password-generator,symbols,CONFIGURATION,kotlin.String,xKMWoX0lLELgvANbNCyz +qr-code-generator,AZTEC-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,AZTEC-height,CONFIGURATION,kotlin.Int,251 +qr-code-generator,AZTEC-layers,CONFIGURATION,kotlin.Int,1 +qr-code-generator,AZTEC-margin,CONFIGURATION,kotlin.Int,2 +qr-code-generator,AZTEC-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,CODABAR-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,CODABAR-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,CODABAR-margin,CONFIGURATION,kotlin.Int,11 +qr-code-generator,CODABAR-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,CODE_128-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,CODE_128-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,CODE_128-margin,CONFIGURATION,kotlin.Int,11 +qr-code-generator,CODE_128-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,CODE_39-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,CODE_39-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,CODE_39-margin,CONFIGURATION,kotlin.Int,11 +qr-code-generator,CODE_39-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,CODE_93-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,CODE_93-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,CODE_93-margin,CONFIGURATION,kotlin.Int,11 +qr-code-generator,CODE_93-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,DATA_MATRIX-compactMode,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,DATA_MATRIX-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,DATA_MATRIX-forceC40,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,DATA_MATRIX-gs1,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,DATA_MATRIX-height,CONFIGURATION,kotlin.Int,251 +qr-code-generator,DATA_MATRIX-margin,CONFIGURATION,kotlin.Int,2 +qr-code-generator,DATA_MATRIX-symbolShape,CONFIGURATION,QrCode-SymbolShapeHint,FORCE_SQUARE +qr-code-generator,DATA_MATRIX-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,EAN_13-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,EAN_13-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,EAN_13-margin,CONFIGURATION,kotlin.Int,11 +qr-code-generator,EAN_13-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,EAN_8-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,EAN_8-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,EAN_8-margin,CONFIGURATION,kotlin.Int,11 +qr-code-generator,EAN_8-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,ITF-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,ITF-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,ITF-margin,CONFIGURATION,kotlin.Int,11 +qr-code-generator,ITF-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,PDF_417-compactMode,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,PDF_417-compactionModeType,CONFIGURATION,QrCode-Compaction,TEXT +qr-code-generator,PDF_417-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,PDF_417-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,PDF_417-insertEcis,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,PDF_417-margin,CONFIGURATION,kotlin.Int,6 +qr-code-generator,PDF_417-maxColumns,CONFIGURATION,kotlin.Int,51 +qr-code-generator,PDF_417-minColumns,CONFIGURATION,kotlin.Int,2 +qr-code-generator,PDF_417-minRows,CONFIGURATION,kotlin.Int,2 +qr-code-generator,PDF_417-setDimensions,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,PDF_417-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,QR_CODE-compactMode,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,QR_CODE-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,QR_CODE-gs1,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,QR_CODE-height,CONFIGURATION,kotlin.Int,201 +qr-code-generator,QR_CODE-margin,CONFIGURATION,kotlin.Int,1 +qr-code-generator,QR_CODE-maskPattern,CONFIGURATION,kotlin.Int,0 +qr-code-generator,QR_CODE-version,CONFIGURATION,kotlin.Int,1 +qr-code-generator,QR_CODE-width,CONFIGURATION,kotlin.Int,201 +qr-code-generator,UPC_A-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,UPC_A-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,UPC_A-margin,CONFIGURATION,kotlin.Int,2 +qr-code-generator,UPC_A-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,UPC_E-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,UPC_E-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,UPC_E-margin,CONFIGURATION,kotlin.Int,2 +qr-code-generator,UPC_E-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,UPC_EAN_EXTENSION-errorCorrection,CONFIGURATION,BarcodeGenerator-ErrorCorrection,L +qr-code-generator,UPC_EAN_EXTENSION-height,CONFIGURATION,kotlin.Int,51 +qr-code-generator,UPC_EAN_EXTENSION-margin,CONFIGURATION,kotlin.Int,2 +qr-code-generator,UPC_EAN_EXTENSION-width,CONFIGURATION,kotlin.Int,251 +qr-code-generator,backgroundColor,CONFIGURATION,com.intellij.ui.JBColor,-16777201 +qr-code-generator,content-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,content-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +qr-code-generator,content-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +qr-code-generator,contentText,INPUT,kotlin.String,DT2jH5zfO6ogjaGtw0VK +qr-code-generator,foregroundColor,CONFIGURATION,com.intellij.ui.JBColor,-16777070 +qr-code-generator,format,CONFIGURATION,BarcodeGenerator-Format,DATA_MATRIX +qr-code-generator,liveGeneration,CONFIGURATION,kotlin.Boolean,false +regular-expression-matcher,extraction-result-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +regular-expression-matcher,extraction-result-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +regular-expression-matcher,extraction-result-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +regular-expression-matcher,extractionPattern,INPUT,kotlin.String,Z1EdY9GRzpiqNvluBsh1 +regular-expression-matcher,input-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +regular-expression-matcher,input-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +regular-expression-matcher,input-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +regular-expression-matcher,inputText,INPUT,kotlin.String,aJBzo2O8JP0CAdUpjDFV +regular-expression-matcher,regexOption,CONFIGURATION,kotlin.Int,1 +regular-expression-matcher,regexText,INPUT,kotlin.String,G2v9Bsys6F97bd4lCbML +regular-expression-matcher,substitution-result-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +regular-expression-matcher,substitution-result-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +regular-expression-matcher,substitution-result-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +regular-expression-matcher,substitutionPattern,INPUT,kotlin.String,tQuuHum4sQfrlsWsvOic +sql-formatting,dialect,CONFIGURATION,SqlFormatter-Dialect,PlSql +sql-formatting,indentSpaces,CONFIGURATION,kotlin.Int,3 +sql-formatting,linesBetweenQueries,CONFIGURATION,kotlin.Int,2 +sql-formatting,liveConversion,CONFIGURATION,kotlin.Boolean,false +sql-formatting,maxColumnLength,CONFIGURATION,kotlin.Int,31 +sql-formatting,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +sql-formatting,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +sql-formatting,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +sql-formatting,sourceFile,INPUT,kotlin.String,ZTBuJxxQTJmXMbmXwImu +sql-formatting,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +sql-formatting,sourceText,INPUT,kotlin.String,kz2QLwD82agGlVOxynYt +sql-formatting,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +sql-formatting,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +sql-formatting,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +sql-formatting,targetFile,INPUT,kotlin.String,Y8w84BAtbDzInqMuAi3E +sql-formatting,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +sql-formatting,uppercase,CONFIGURATION,kotlin.Boolean,false +text-case-transformer,individualDelimiter,CONFIGURATION,kotlin.String,oTyDR7xJxdkQqzRu3vsU +text-case-transformer,inputTextCase,CONFIGURATION,TextCaseTransformer-TextCase,PASCAL_CASE +text-case-transformer,liveConversion,CONFIGURATION,kotlin.Boolean,false +text-case-transformer,originalParsingMode,CONFIGURATION,TextCaseTransformer-OriginalParsingMode,FIXED_TEXT_CASE +text-case-transformer,outputTextCase,CONFIGURATION,TextCaseTransformer-TextCase,PASCAL_SNAKE_CASE +text-case-transformer,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-case-transformer,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-case-transformer,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-case-transformer,sourceFile,INPUT,kotlin.String,0fkzJXO7egPIRER8G2Os +text-case-transformer,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-case-transformer,sourceText,INPUT,kotlin.String,rbaSzRRU2AG2IKxJDOXL +text-case-transformer,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-case-transformer,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-case-transformer,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-case-transformer,targetFile,INPUT,kotlin.String,43YfipktZPC0XrAieCyw +text-case-transformer,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-diff,firstText,INPUT,kotlin.String,3o3bjR7l7eM1b8z7jcpU +text-diff,secondText,INPUT,kotlin.String,mLnWaDGV1y6XnbdSP14M +text-filter,filteringContainingModeText,INPUT,kotlin.String,ugnDPg7ITBYWk7Iky6qq +text-filter,filteringMode,CONFIGURATION,TextFilterTransformer-FilteringMode,NOT_CONTAINING +text-filter,filteringNotContainingModeText,INPUT,kotlin.String,LgPA6RnmNxsdpOvWZRYZ +text-filter,filteringRegexModeOptions,CONFIGURATION,kotlin.Int,1 +text-filter,filteringRegexModeText,INPUT,kotlin.String,9YkWNSLJzyUk4OWHWou5 +text-filter,liveConversion,CONFIGURATION,kotlin.Boolean,false +text-filter,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-filter,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-filter,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-filter,sourceFile,INPUT,kotlin.String,ZSy6SqLd4msaY0t3Vj0K +text-filter,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-filter,sourceText,INPUT,kotlin.String,HEK87uVVv9khuU2iks62 +text-filter,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-filter,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-filter,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-filter,targetFile,INPUT,kotlin.String,evQgrgInYYTiKzhJONSS +text-filter,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-filter,tokenSelectionMode,CONFIGURATION,TextFilterTransformer-TokenMode,WORD +text-format-converter,firstLanguage,CONFIGURATION,CodeFormattingConverter-Language,YAML +text-format-converter,liveConversion,CONFIGURATION,kotlin.Boolean,false +text-format-converter,secondLanguage,CONFIGURATION,CodeFormattingConverter-Language,XML +text-format-converter,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-format-converter,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-format-converter,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-format-converter,sourceFile,INPUT,kotlin.String,UduVUUPqqNaghM27TmTK +text-format-converter,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-format-converter,sourceText,INPUT,kotlin.String,Szdv3BZxI4mmqYfBIW0d +text-format-converter,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-format-converter,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-format-converter,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-format-converter,targetFile,INPUT,kotlin.String,HxCPB9XrtbyVVPxG64b2 +text-format-converter,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-format-converter,targetText,INPUT,kotlin.String,Szdv3BZxI4mmqYfBIW0d +text-sorting-transformer,caseInsensitive,CONFIGURATION,kotlin.Boolean,true +text-sorting-transformer,liveConversion,CONFIGURATION,kotlin.Boolean,false +text-sorting-transformer,removeBlankWords,CONFIGURATION,kotlin.Boolean,false +text-sorting-transformer,removeDuplicates,CONFIGURATION,kotlin.Boolean,false +text-sorting-transformer,reverseOrder,CONFIGURATION,kotlin.Boolean,true +text-sorting-transformer,sortedIndividualJoinWordsDelimiter,CONFIGURATION,kotlin.String,1rAEPbCmw68ryLbLeAbL +text-sorting-transformer,sortedJoinWordsDelimiter,CONFIGURATION,TextSortingTransformer-WordsDelimiter,SPACE +text-sorting-transformer,sortingOrder,CONFIGURATION,TextSortingTransformer-SortingOrder,WORD_LENGTH +text-sorting-transformer,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-sorting-transformer,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-sorting-transformer,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-sorting-transformer,sourceFile,INPUT,kotlin.String,aUlKDPbdUnQx8oPuoo8c +text-sorting-transformer,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-sorting-transformer,sourceText,INPUT,kotlin.String,cXlfDJORxHE5BQSVlExl +text-sorting-transformer,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-sorting-transformer,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-sorting-transformer,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +text-sorting-transformer,targetFile,INPUT,kotlin.String,k6On0eq4m4ipijxUZD4d +text-sorting-transformer,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +text-sorting-transformer,trimWords,CONFIGURATION,kotlin.Boolean,false +text-sorting-transformer,unsortedIndividualSplitWordsDelimiter,CONFIGURATION,kotlin.String,qgwspRiumDdu2h4zdZya +text-sorting-transformer,unsortedPredefinedDelimiter,CONFIGURATION,TextSortingTransformer-WordsDelimiter,SPACE +text-statistic,text,INPUT,kotlin.String,pDwFuexdCNVC6FbGiuOu +text-statistic,text-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +text-statistic,text-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +text-statistic,text-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +ulid-generator,bulk-generation-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +ulid-generator,bulk-generation-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +ulid-generator,bulk-generation-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +ulid-generator,generateMonotonicUlid,CONFIGURATION,kotlin.Boolean,true +ulid-generator,individualTime,CONFIGURATION,kotlin.Long,1777651542759 +ulid-generator,ulidFormat,CONFIGURATION,UlidGenerator-UlidFormat,TO_LOWERCASE +ulid-generator,useIndividualTime,CONFIGURATION,kotlin.Boolean,true +unarchiver,archiveTreeSortingMode,CONFIGURATION,Unarchiver-SortingMode,FILENAME_DESC +unarchiver,clearTargetDirectory,CONFIGURATION,kotlin.Boolean,true +unarchiver,createArchiveFilenameSubDirectory,CONFIGURATION,kotlin.Boolean,false +unarchiver,createParentDirectories,CONFIGURATION,kotlin.Boolean,false +unarchiver,lastSelectedOpenedDirectoryPath,CONFIGURATION,kotlin.String,ywyfcCDd0TytZY6BCQH5 +unarchiver,lastSelectedTargetDirectoryPath,CONFIGURATION,kotlin.String,TaKUgHO7R6Q4SCPDgTRV +unarchiver,openFileInEditorOnDoubleClick,CONFIGURATION,kotlin.Boolean,false +unarchiver,openTargetDirectoryAfterExtraction,CONFIGURATION,kotlin.Boolean,false +unarchiver,preserveDirectoryStructure,CONFIGURATION,kotlin.Boolean,false +unarchiver,preserveFileAttributes,CONFIGURATION,kotlin.Boolean,false +unarchiver,showArchiveNodeTotalNumberOfChildren,CONFIGURATION,kotlin.Boolean,false +unarchiver,showArchiveNodeUncompressedSize,CONFIGURATION,kotlin.Boolean,false +units-converter,baseConverter_baseTwoInput,INPUT,kotlin.String,000 +units-converter,baseConverter_showOnlyCommonBases,CONFIGURATION,kotlin.Boolean,false +units-converter,dataSizeConverter_bitDataSizeValue,INPUT,java.math.BigDecimal,1 +units-converter,dataSizeConverter_decimalPlaces,CONFIGURATION,kotlin.Int,6 +units-converter,dataSizeConverter_parsingLocale,CONFIGURATION,dev.turingcomplete.intellijdevelopertoolsplugin.common.LocaleContainer,fil +units-converter,dataSizeConverter_precision,CONFIGURATION,kotlin.Int,51 +units-converter,dataSizeConverter_roundingMode,CONFIGURATION,MathContextUnitConverter-RoundingMode,HALF_DOWN +units-converter,dataSizeConverter_showLargeDataUnits,CONFIGURATION,kotlin.Boolean,true +units-converter,lastSelectedUnitConverterIndex,CONFIGURATION,kotlin.Int,1 +units-converter,timeConverter_decimalPlaces,CONFIGURATION,kotlin.Int,6 +units-converter,timeConverter_parsingLocale,CONFIGURATION,dev.turingcomplete.intellijdevelopertoolsplugin.common.LocaleContainer,cv-RU +units-converter,timeConverter_precision,CONFIGURATION,kotlin.Int,51 +units-converter,timeConverter_roundingMode,CONFIGURATION,MathContextUnitConverter-RoundingMode,HALF_DOWN +units-converter,timeConverter_timeNanoseconds,INPUT,java.math.BigDecimal,1 +units-converter,transferRateConverter_bitTransferRateValue,INPUT,java.math.BigDecimal,1 +units-converter,transferRateConverter_decimalPlaces,CONFIGURATION,kotlin.Int,6 +units-converter,transferRateConverter_parsingLocale,CONFIGURATION,dev.turingcomplete.intellijdevelopertoolsplugin.common.LocaleContainer,gu-IN +units-converter,transferRateConverter_precision,CONFIGURATION,kotlin.Int,51 +units-converter,transferRateConverter_roundingMode,CONFIGURATION,MathContextUnitConverter-RoundingMode,HALF_DOWN +units-converter,transferRateConverter_showLargeDataUnits,CONFIGURATION,kotlin.Boolean,true +units-converter,transferRateConverter_timeDimension,CONFIGURATION,TransferRateConverter-TransferRateTimeDimension,MINUTES +units-converter,transferRateConverter_useCombinedAbbreviationNotation,CONFIGURATION,kotlin.Boolean,false +url-base64-encoder-decoder,liveConversion,CONFIGURATION,kotlin.Boolean,false +url-base64-encoder-decoder,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +url-base64-encoder-decoder,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +url-base64-encoder-decoder,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +url-base64-encoder-decoder,sourceFile,INPUT,kotlin.String,H5f3p7USLbOJj5JbgpZS +url-base64-encoder-decoder,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +url-base64-encoder-decoder,sourceText,INPUT,kotlin.String,GIbfpTEMSgYmW8q8TZyf +url-base64-encoder-decoder,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +url-base64-encoder-decoder,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +url-base64-encoder-decoder,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +url-base64-encoder-decoder,targetFile,INPUT,kotlin.String,1f2IIDBgmzVtBRQhdquI +url-base64-encoder-decoder,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +url-base64-encoder-decoder,targetText,INPUT,kotlin.String,R0liZnBURU1TZ1ltVzhxOFRaeWY= +url-encoding-encoder-decoder,liveConversion,CONFIGURATION,kotlin.Boolean,false +url-encoding-encoder-decoder,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +url-encoding-encoder-decoder,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +url-encoding-encoder-decoder,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +url-encoding-encoder-decoder,sourceFile,INPUT,kotlin.String,eP6MsGoi23KHhi4G1DuF +url-encoding-encoder-decoder,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +url-encoding-encoder-decoder,sourceText,INPUT,kotlin.String,yEhhgUsNfKTaGktAV2aV +url-encoding-encoder-decoder,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +url-encoding-encoder-decoder,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +url-encoding-encoder-decoder,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +url-encoding-encoder-decoder,targetFile,INPUT,kotlin.String,TZfSQPGs55VVYlUSoQtb +url-encoding-encoder-decoder,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +url-encoding-encoder-decoder,targetText,INPUT,kotlin.String,yEhhgUsNfKTaGktAV2aV +uuid-generator,UUIDv1IndividualMacAddress,CONFIGURATION,kotlin.String,nU5zzH9V9Q83u5Kf5B0s +uuid-generator,UUIDv1LocalInterface,CONFIGURATION,kotlin.String,DEQrfwLIUvvnEWWrhhNN +uuid-generator,UUIDv1MacAddressGenerationMode,CONFIGURATION,MacAddressBasedUuidGenerator-MacAddressGenerationMode,INDIVIDUAL +uuid-generator,UUIDv3IndividualNamespace,CONFIGURATION,kotlin.String,9BinQfpSjvZBDWojwz5v +uuid-generator,UUIDv3Name,CONFIGURATION,kotlin.String,GlX9scQEZ260QGz46RjT +uuid-generator,UUIDv3NamespaceMode,CONFIGURATION,NamespaceAndNameBasedUuidGenerator-NamespaceMode,INDIVIDUAL +uuid-generator,UUIDv3PredefinedNamespace,CONFIGURATION,NamespaceAndNameBasedUuidGenerator-PredefinedNamespace,URL +uuid-generator,UUIDv5IndividualNamespace,CONFIGURATION,kotlin.String,7YP0gDlT80K0CDeKchP0 +uuid-generator,UUIDv5Name,CONFIGURATION,kotlin.String,DXwcWtMXw92VzrCnRvd3 +uuid-generator,UUIDv5NamespaceMode,CONFIGURATION,NamespaceAndNameBasedUuidGenerator-NamespaceMode,INDIVIDUAL +uuid-generator,UUIDv5PredefinedNamespace,CONFIGURATION,NamespaceAndNameBasedUuidGenerator-PredefinedNamespace,URL +uuid-generator,UUIDv6IndividualMacAddress,CONFIGURATION,kotlin.String,4MRkaTtL3nSa2ck0ragZ +uuid-generator,UUIDv6LocalInterface,CONFIGURATION,kotlin.String,PLzcdpNsEi8UVLVvJGb1 +uuid-generator,UUIDv6MacAddressGenerationMode,CONFIGURATION,MacAddressBasedUuidGenerator-MacAddressGenerationMode,INDIVIDUAL +uuid-generator,bulk-generation-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +uuid-generator,bulk-generation-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +uuid-generator,bulk-generation-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +uuid-generator,version,CONFIGURATION,UuidVersion,V5 +xml-text-escape,liveConversion,CONFIGURATION,kotlin.Boolean,false +xml-text-escape,source-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +xml-text-escape,source-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +xml-text-escape,source-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +xml-text-escape,sourceFile,INPUT,kotlin.String,REUkl1gB5mkYd9zTxdLK +xml-text-escape,sourceFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +xml-text-escape,sourceText,INPUT,kotlin.String,4uaSTdBicK25VbDru2EK +xml-text-escape,target-editor-showSpecialCharacters,CONFIGURATION,kotlin.Boolean,true +xml-text-escape,target-editor-showWhitespaces,CONFIGURATION,kotlin.Boolean,true +xml-text-escape,target-editor-softWraps,CONFIGURATION,kotlin.Boolean,false +xml-text-escape,targetFile,INPUT,kotlin.String,Ok4jZXvqsTrRwNuz1Xa5 +xml-text-escape,targetFileWriteFormat,CONFIGURATION,FileHandling-WriteFormat,HEX +xml-text-escape,targetText,INPUT,kotlin.String,4uaSTdBicK25VbDru2EK diff --git a/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/instance-settings-persisted-state.xml b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/instance-settings-persisted-state.xml new file mode 100644 index 0000000..c7b5f49 --- /dev/null +++ b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/instance-settings-persisted-state.xml @@ -0,0 +1,740 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/removed-configuration-properties.csv b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/removed-configuration-properties.csv new file mode 100644 index 0000000..e66e120 --- /dev/null +++ b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/removed-configuration-properties.csv @@ -0,0 +1 @@ +developerToolId,propertyKey diff --git a/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/renamed-configuration-properties.csv b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/renamed-configuration-properties.csv new file mode 100644 index 0000000..61fd693 --- /dev/null +++ b/src/test/resources/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/instancesettings/8.0.0/renamed-configuration-properties.csv @@ -0,0 +1 @@ +developerToolId,oldPropertyKey,newPropertyKey From c46192e4bb74dde4b341d8d5652c0e1e38e3fcb9 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Fri, 1 May 2026 18:24:30 +0200 Subject: [PATCH 09/11] chore: GitHub issue and PR template --- .github/ISSUE_TEMPLATE/bug_report.yml | 79 +++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 55 +++++++++++++ .../ISSUE_TEMPLATE/pull_request_template.md | 33 ++++++++ 4 files changed, 172 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..85fde7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,79 @@ +name: Bug report +description: Report a reproducible problem with the plugin +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for opening an issue! 🙌 + + Please provide as much detail as possible so we can reproduce and investigate the problem. + + - type: textarea + id: description + attributes: + label: Bug description + description: What happened? + placeholder: Describe the problem clearly. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Please provide a detailed, reproducible description. + placeholder: | + 1. Open ... + 2. Click ... + 3. See error ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: What actually happened? + validations: + required: true + + - type: textarea + id: stacktrace + attributes: + label: Stack trace or logs + description: Paste any stack trace or relevant logs here, if available. + render: text + + - type: textarea + id: screenshots + attributes: + label: Screenshots or screen recordings + description: Add screenshots or recordings if they help explain the issue. + + - type: input + id: ide-version + attributes: + label: JetBrains IDE and version + placeholder: IntelliJ IDEA 2024.3, WebStorm 2024.3, PyCharm 2024.3, etc. + + - type: input + id: plugin-version + attributes: + label: Plugin version + placeholder: 1.2.3 + + - type: textarea + id: additional-context + attributes: + label: Additional context + placeholder: Anything else that may help? \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..cfcd4e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions or support + url: https://github.com/marcelkliemannel/intellij-developer-tools-plugin/discussions + about: Please use Discussions for questions, support requests, and general ideas. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..bff0395 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,55 @@ +name: Feature request +description: Suggest an improvement for the plugin +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for opening an issue! 🙌 + + Please note: this is a general IntelliJ plugin intended to work across all JetBrains IDEs. + + Features that are specific to one programming language, framework, or technology stack are usually out of scope for this plugin. + + - type: textarea + id: problem + attributes: + label: Problem description + description: What problem are you trying to solve? + placeholder: Describe the use case or pain point. + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What would you like to happen? + placeholder: Describe the feature you would like to see. + validations: + required: true + + - type: dropdown + id: scope-confirmation + attributes: + label: Scope confirmation + description: Is this feature general enough to work across JetBrains IDEs? + options: + - Yes, this is a general feature for JetBrains IDEs + - No, this is specific to a language, framework, or technology stack + - Not sure + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + placeholder: Describe any alternatives or workarounds you have considered. + + - type: textarea + id: additional-context + attributes: + label: Additional context + placeholder: Add screenshots, examples, or other context here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/ISSUE_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..2ea64f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pull_request_template.md @@ -0,0 +1,33 @@ +## 🙌 Thanks for your contribution + +We really appreciate your effort in improving this plugin. + +--- + +## ⚠️ Before submitting a PR + +Please note: + +- It is **required to open an issue first** before submitting a pull request. +- The issue should clearly describe the problem and **propose a solution or approach**. +- This ensures that changes are aligned with the overall direction of the project. + +👉 **Unsolicited pull requests (without prior discussion) are usually not appropriate and may be closed.** + +--- + +## 🔗 Related Issue + +Please link the issue this PR is based on: + +Fixes #... + +--- + +## ✨ What does this PR do? + +Describe your changes clearly: +- What problem does it solve? +- What was your approach? + +--- From ddaaab8ff1fc4901a3dbe32e93861f9f7b39843b Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Fri, 1 May 2026 18:25:54 +0200 Subject: [PATCH 10/11] chore: Fix linting issues --- .../DeveloperToolsInstanceSettingsTest.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt index 588294a..d148996 100644 --- a/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt +++ b/src/test/kotlin/dev/turingcomplete/intellijdevelopertoolsplugin/integrationtest/DeveloperToolsInstanceSettingsTest.kt @@ -16,20 +16,6 @@ import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolsIn import dev.turingcomplete.intellijdevelopertoolsplugin.settings.DeveloperToolsInstanceSettingsLegacy import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.testfixtures.DeveloperUiToolUnderTest import dev.turingcomplete.intellijdevelopertoolsplugin.tool.ui.testfixtures.DeveloperUiToolsInstances.createDeveloperUiToolsUnderTest -import org.apache.commons.csv.CSVFormat -import org.apache.commons.csv.CSVParser -import org.apache.commons.csv.CSVPrinter -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.DynamicContainer -import org.junit.jupiter.api.DynamicContainer.dynamicContainer -import org.junit.jupiter.api.DynamicNode -import org.junit.jupiter.api.DynamicTest -import org.junit.jupiter.api.DynamicTest.dynamicTest -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestFactory -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource import java.math.BigDecimal import java.nio.file.Files import java.nio.file.Path @@ -45,6 +31,20 @@ import kotlin.io.path.writeText import kotlin.io.path.writer import kotlin.reflect.KClass import kotlin.streams.asSequence +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVParser +import org.apache.commons.csv.CSVPrinter +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.DynamicContainer +import org.junit.jupiter.api.DynamicContainer.dynamicContainer +import org.junit.jupiter.api.DynamicNode +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource class DeveloperToolsInstanceSettingsTest : IdeaTest() { // -- Properties ---------------------------------------------------------- // From 9ef05c3a4bdb0ad2dc0253df5f2b6cd94effca21 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Fri, 1 May 2026 19:09:33 +0200 Subject: [PATCH 11/11] chore: Modernize GitHub action --- .github/workflows/checkPlugin.yml | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/checkPlugin.yml b/.github/workflows/checkPlugin.yml index 12c2976..87c8708 100644 --- a/.github/workflows/checkPlugin.yml +++ b/.github/workflows/checkPlugin.yml @@ -4,10 +4,13 @@ on: push: branches: [ main ] pull_request: - branches: [ main ] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read actions: read @@ -16,27 +19,31 @@ permissions: jobs: verifyPlugin: runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v2 + - uses: actions/checkout@v6 + + - uses: actions/setup-java@v5 with: - distribution: 'adopt' + distribution: 'temurin' java-version: '21' + - uses: gradle/actions/setup-gradle@v6 + - name: Run Checks uses: coactions/setup-xvfb@v1 with: run: ./gradlew check --stacktrace + - name: Verify Plugin + run: ./gradlew verifyPlugin --stacktrace + - name: Test Report - uses: dorny/test-reporter@v1 - if: success() || failure() + uses: dorny/test-reporter@v3 + if: ${{ !cancelled() }} + continue-on-error: true with: name: JUnit Tests path: '**/build/test-results/test/TEST-*.xml' reporter: java-junit - - - name: Verify Plugin - run: ./gradlew verifyPlugin --stacktrace -