Skip to content

Commit 150d60b

Browse files
authored
Merge pull request #15 from Flyrell/features/253
feat: Support latest Intellij products 2025.3
2 parents edf9530 + 49923e3 commit 150d60b

File tree

8 files changed

+261
-22
lines changed

8 files changed

+261
-22
lines changed

README.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,8 @@ You can find the plugin on [IntelliJ marketplace](https://plugins.jetbrains.com/
1212

1313
## Compatibility
1414

15-
Manually verified with these products…
16-
17-
- IntelliJ IDEA Community Edition 2025.2+
18-
- IntelliJ IDEA Ultimate 2025.2+
19-
- PhpStorm 2025.2+
20-
- WebStorm 2025.2+
15+
Manually verified with IntelliJ IDEA Unified distribution 2025.3+
2116

2217
## ❤️🙏 Love & Thanks
2318

2419
- [tscharke](https://github.com/tscharke) for contributing
25-

build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
23

34
fun properties(key: String) = providers.gradleProperty(key)
45

@@ -24,7 +25,12 @@ dependencies {
2425
val type = properties("platformType").get()
2526
val version = properties("platformVersion").get()
2627
create(type, version)
28+
29+
testFramework(TestFrameworkType.Platform)
30+
bundledPlugin("JavaScript")
2731
}
32+
33+
testImplementation(kotlin("test"))
2834
}
2935

3036
tasks {

gradle.properties

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ org.gradle.caching=true
77

88
pluginName=html-attribute-folder
99
pluginGroup=dev.zbinski
10-
pluginVersion=1.3.0
11-
platformVersion=2025.2
10+
pluginVersion=1.4.0
11+
platformVersion=2025.3
1212
# @see https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-types.html#IntelliJPlatformType
13-
platformType=IC
13+
platformType=IU
1414
# @see https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html#platformVersions
15-
pluginSinceBuild=252
16-
pluginUntilBuild=252.*
15+
pluginSinceBuild=253
16+
pluginUntilBuild=253.*
1717
javaVersion=21

src/main/kotlin/dev/zbinski/htmlattributefolder/AttributeFolder.kt

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,53 @@ class AttributeFolder: FoldingBuilderEx(), DumbAware {
2020
for (item in getAttributes(Array(1) { root })) {
2121
var end: Int
2222
var start: Int
23+
2324
if (settings.foldingMethod == 1) {
2425
end = item.attribute.textRange.endOffset
2526
start = item.attribute.textRange.startOffset
2627
} else {
27-
val len = item.attributeName.length + settings.attributeSeparator.length + settings.attributeWrapper.length
28-
start = item.attribute.textRange.startOffset + len
29-
end = item.attribute.textRange.endOffset - settings.attributeWrapper.length
28+
val text = item.attribute.text
29+
val base = item.attribute.textRange.startOffset
30+
31+
// Find '=' then the first non-space char after it
32+
val eq = text.indexOf(settings.attributeSeparator)
33+
if (eq < 0) continue
34+
var i = eq + 1
35+
while (i < text.length && text[i].isWhitespace()) i++
36+
if (i >= text.length) continue
37+
38+
when (text[i]) {
39+
// name="value" or name='value'
40+
'"', '\'' -> {
41+
val quote = text[i]
42+
val open = i
43+
val close = text.indexOf(quote, startIndex = open + 1)
44+
if (close < 0) continue
45+
46+
start = base + open + 1
47+
end = base + close
48+
}
49+
// name={...} or name={{...}}
50+
'{' -> {
51+
val outerOpen = i
52+
val outerClose = findMatchingBrace(text, outerOpen) ?: continue
53+
54+
val isDouble = outerOpen + 1 < text.length && text[outerOpen + 1] == '{'
55+
if (isDouble) {
56+
val innerOpen = outerOpen + 1
57+
val innerClose = findMatchingBrace(text, innerOpen) ?: continue
58+
59+
// Fold inside INNER braces INCLUDING whitespace so output becomes: style={{__PLACEHOLDER__}}
60+
start = base + innerOpen + 1
61+
end = base + innerClose
62+
} else {
63+
// Fold inside single braces INCLUDING whitespace: name={__PLACEHOLDER__}
64+
start = base + outerOpen + 1
65+
end = base + outerClose
66+
}
67+
}
68+
else -> continue
69+
}
3070
}
3171

3272
if (end > start) {
@@ -57,24 +97,46 @@ class AttributeFolder: FoldingBuilderEx(), DumbAware {
5797
return settings.collapseByDefault
5898
}
5999

100+
private fun findMatchingBrace(text: String, openIndex: Int): Int? {
101+
if (openIndex !in text.indices || text[openIndex] != '{') return null
102+
var depth = 0
103+
var i = openIndex
104+
while (i < text.length) {
105+
when (text[i]) {
106+
'{' -> depth++
107+
'}' -> {
108+
depth--
109+
if (depth == 0) return i
110+
}
111+
}
112+
i++
113+
}
114+
return null
115+
}
116+
60117
private fun getAttributes(
61118
elements: Array<PsiElement>,
62119
attributes: ArrayList<String> = settings.attributes
63120
): Sequence<Attribute> = sequence {
64121
for (child in elements) {
122+
val t = child.text
65123
for (attributeName in attributes) {
66-
val attributeBeginning = attributeName + settings.attributeSeparator + settings.attributeWrapper
67-
if (child.text.startsWith(attributeBeginning)) {
124+
val startsLikeAttribute =
125+
t.startsWith("$attributeName=\"") ||
126+
t.startsWith("$attributeName='") ||
127+
t.startsWith("$attributeName={")
128+
129+
if (startsLikeAttribute) {
68130
yield(object : Attribute {
69131
override val attribute = child
70132
override val attributeName = attributeName
71133
})
72134
}
135+
}
73136

74-
val items = getAttributes(child.children, arrayListOf(attributeName)).iterator()
75-
while (items.hasNext()) {
76-
yield(items.next())
77-
}
137+
val items = getAttributes(child.children, attributes).iterator()
138+
while (items.hasNext()) {
139+
yield(items.next())
78140
}
79141
}
80142
}

src/main/resources/META-INF/plugin.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-name -->
88
<name>HTML Attribute Folder</name>
99

10-
<version>1.3.0</version>
10+
<version>1.4.0</version>
1111

1212
<!-- A displayed Vendor name or Organization ID displayed on the Plugins Page. -->
1313
<vendor email="dawid@zbinski.dev" url="https://zbinski.dev">Dawid Zbiński</vendor>
@@ -29,6 +29,7 @@
2929
<depends>com.intellij.modules.platform</depends>
3030
<depends>com.intellij.modules.lang</depends>
3131
<depends>com.intellij.modules.xml</depends>
32+
<depends optional="true">com.intellij.modules.javascript</depends>
3233

3334
<!-- Extension points defined by the plugin.
3435
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package dev.zbinski.htmlattributefolder
2+
3+
private const val HTML_SNIPPET =
4+
"""<div class="a b" className="c d" style="background-color: red;" data-foo="foo-data" />"""
5+
6+
class AttributeFolderHTMLTest : BaseAttributeFolderTest() {
7+
fun testFoldingHtmlAttributesCollapsed() {
8+
assertContains(document.text, """class="a b"""")
9+
assertContains(document.text, """className="c d"""")
10+
assertContains(document.text, """style="background-color: red;"""")
11+
assertContains(document.text, """data-foo="foo-data"""")
12+
13+
configureAttributeFolder(collapseByDefault = true)
14+
15+
val visualText = applyPluginFoldingAndRender()
16+
assertContains(visualText, """class="__PLACEHOLDER__"""")
17+
assertContains(visualText, """className="__PLACEHOLDER__"""")
18+
assertContains(visualText, """style="background-color: red;"""")
19+
assertContains(visualText, """data-foo="foo-data"""")
20+
}
21+
22+
fun testFoldingHtmlAttributesUncollapsed() {
23+
assertContains(document.text, """class="a b"""")
24+
assertContains(document.text, """className="c d"""")
25+
assertContains(document.text, """style="background-color: red;"""")
26+
assertContains(document.text, """data-foo="foo-data"""")
27+
28+
configureAttributeFolder(collapseByDefault = false)
29+
30+
val visualText = applyPluginFoldingAndRender()
31+
assertContains(visualText, """class="a b"""")
32+
assertContains(visualText, """className="c d"""")
33+
assertContains(visualText, """style="background-color: red;"""")
34+
assertContains(visualText, """data-foo="foo-data"""")
35+
}
36+
37+
override fun setUp() {
38+
super.setUp()
39+
setupDocument("test.html", HTML_SNIPPET)
40+
}
41+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package dev.zbinski.htmlattributefolder
2+
3+
private const val TSX_SNIPPET = """
4+
export const Component = () => (
5+
<div class="a b" className="c d" style={{ backgroundColor: "red", nested: { "foo": "bar" } }} data-foo="foo-data" />
6+
)
7+
"""
8+
9+
class AttributeFolderTSXTest : BaseAttributeFolderTest() {
10+
fun testFoldingTSXAttributesCollapsed() {
11+
assertContains(document.text, """class="a b"""")
12+
assertContains(document.text, """className="c d"""")
13+
assertContains(document.text, """style={{ backgroundColor: "red", nested: { "foo": "bar" } }}""")
14+
assertContains(document.text, """data-foo="foo-data"""")
15+
16+
configureAttributeFolder(collapseByDefault = true, arrayListOf("class", "className", "style"))
17+
18+
val visualText = applyPluginFoldingAndRender()
19+
assertContains(visualText, """class="__PLACEHOLDER__"""")
20+
assertContains(visualText, """className="__PLACEHOLDER__"""")
21+
assertContains(visualText, """style={{__PLACEHOLDER__}}""")
22+
assertContains(visualText, """data-foo="foo-data"""")
23+
}
24+
25+
fun testFoldingTSXAttributesUncollapsed() {
26+
assertContains(document.text, """class="a b"""")
27+
assertContains(document.text, """className="c d"""")
28+
assertContains(document.text, """style={{ backgroundColor: "red", nested: { "foo": "bar" } }}""")
29+
assertContains(document.text, """data-foo="foo-data"""")
30+
31+
configureAttributeFolder(collapseByDefault = false, arrayListOf("class", "className", "style"))
32+
33+
val visualText = applyPluginFoldingAndRender()
34+
assertContains(visualText, """class="a b"""")
35+
assertContains(visualText, """className="c d"""")
36+
assertContains(visualText, """style={{ backgroundColor: "red", nested: { "foo": "bar" } }}""")
37+
assertContains(visualText, """data-foo="foo-data"""")
38+
}
39+
40+
override fun setUp() {
41+
super.setUp()
42+
setupDocument("test.tsx", TSX_SNIPPET)
43+
skipTestIfJSXIsNotSupported()
44+
}
45+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package dev.zbinski.htmlattributefolder
2+
3+
import com.intellij.openapi.editor.Document
4+
import com.intellij.openapi.editor.FoldRegion
5+
import com.intellij.psi.PsiFile
6+
import com.intellij.testFramework.fixtures.BasePlatformTestCase
7+
import org.junit.Assume
8+
9+
abstract class BaseAttributeFolderTest : BasePlatformTestCase() {
10+
protected lateinit var file: PsiFile
11+
protected lateinit var document: Document
12+
13+
protected fun setupDocument(fileName: String, text: String) {
14+
file = myFixture.configureByText(fileName, text)
15+
document = myFixture.getDocument(file)
16+
}
17+
18+
protected fun configureAttributeFolder(collapseByDefault: Boolean, listOfAttributes: ArrayList<String> = arrayListOf("class", "className")) {
19+
val state = AttributeFolderState.instance
20+
state.attributes = listOfAttributes
21+
state.foldingMethod = 0
22+
state.collapseByDefault = collapseByDefault
23+
state.placeholder = "__PLACEHOLDER__"
24+
}
25+
26+
protected fun applyPluginFoldingAndRender(): String {
27+
val builder = AttributeFolder()
28+
val descriptors = builder.buildFoldRegions(file, document, false)
29+
val editor = myFixture.editor
30+
31+
editor.foldingModel.runBatchFoldingOperation {
32+
for (d in descriptors) {
33+
val region = editor.foldingModel.addFoldRegion(
34+
d.range.startOffset,
35+
d.range.endOffset,
36+
builder.getPlaceholderText(d.element)
37+
)
38+
if (region != null) {
39+
region.isExpanded = !builder.isCollapsedByDefault(d.element)
40+
}
41+
}
42+
}
43+
44+
return renderVisualText(document.text, editor.foldingModel.allFoldRegions.toList())
45+
}
46+
47+
protected fun renderVisualText(documentText: String, regions: List<FoldRegion>): String {
48+
val collapsed = regions.filter { !it.isExpanded }
49+
.sortedBy { it.startOffset }
50+
51+
val sb = StringBuilder()
52+
var i = 0
53+
54+
for (r in collapsed) {
55+
if (r.startOffset < i) continue
56+
sb.append(documentText.substring(i, r.startOffset))
57+
sb.append(r.placeholderText ?: "")
58+
i = r.endOffset
59+
}
60+
61+
sb.append(documentText.substring(i))
62+
return sb.toString()
63+
}
64+
65+
protected fun assertContains(actual: String, expectedSubstring: String, context: String = "") {
66+
kotlin.test.assertTrue(
67+
actual.contains(expectedSubstring),
68+
message = buildString {
69+
appendLine("Expected substring not found:")
70+
appendLine(" expected: $expectedSubstring")
71+
appendLine()
72+
appendLine(" actual:")
73+
appendLine(actual)
74+
if (context.isNotBlank()) {
75+
appendLine()
76+
appendLine(" context:")
77+
appendLine(context)
78+
}
79+
}
80+
)
81+
}
82+
83+
protected fun skipTestIfJSXIsNotSupported() {
84+
val languageId = file.language.id
85+
Assume.assumeTrue(
86+
"Skipping test: JSX/TSX not supported in this test runtime (languageId=$languageId)",
87+
languageId.contains("TypeScript", ignoreCase = true) && languageId.contains("JSX", ignoreCase = true)
88+
)
89+
}
90+
}

0 commit comments

Comments
 (0)