Skip to content

Commit 99df540

Browse files
committed
Enhance duplicate icon name resolution with case-insensitive checks
1 parent b05c4b2 commit 99df540

File tree

7 files changed

+115
-28
lines changed

7 files changed

+115
-28
lines changed

tools/cli/src/main/kotlin/io/github/composegears/valkyrie/cli/command/SvgXmlToImageVectorCommand.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ private fun svgXml2ImageVector(
224224
.filter { it.value.size > 1 }
225225
.keys
226226
.toList()
227+
.sorted()
227228

228229
if (exactDuplicates.isNotEmpty()) {
229230
outputError(
@@ -240,6 +241,7 @@ private fun svgXml2ImageVector(
240241
.values
241242
.flatten()
242243
.distinct()
244+
.sorted()
243245

244246
if (caseInsensitiveDuplicates.isNotEmpty()) {
245247
outputError(

tools/gradle-plugin/src/main/kotlin/io/github/composegears/valkyrie/gradle/internal/task/GenerateImageVectorsTask.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
119119
// Check for duplicates with nested pack awareness
120120
validateDuplicates(iconFiles.files.toList(), iconNames)
121121

122-
123122
if (iconPack.isPresent && iconPack.get().targetSourceSet.get() == sourceSet.get()) {
124123
generateIconPack(outputDirectory = outputDirectory)
125124
}
@@ -405,12 +404,21 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
405404
if (nestedPacks.isNotEmpty()) {
406405
// Build map: file -> nested pack name
407406
val sourceFolderToNestedPack = nestedPacks.associateBy { it.sourceFolder.get() }
408-
val fileToNestedPack = files.associateWith { file ->
407+
408+
// When useFlatPackage is false, only validate files that are in configured nested pack folders
409+
// (matching the behavior of generateIconsForNestedPacks)
410+
val filesToValidate = if (useFlatPackage) {
411+
files
412+
} else {
413+
files.filter { file -> sourceFolderToNestedPack.containsKey(file.parentFile.name) }
414+
}
415+
416+
val fileToNestedPack = filesToValidate.associateWith { file ->
409417
sourceFolderToNestedPack[file.parentFile.name]?.name?.get()
410418
}
411419

412420
// Group by nested pack (or single group if useFlatPackage)
413-
val iconsByPack = files.groupBy { file ->
421+
val iconsByPack = filesToValidate.groupBy { file ->
414422
if (useFlatPackage) {
415423
pack.name.get() // All in same pack when flat
416424
} else {
@@ -428,6 +436,7 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
428436
.filter { it.value.size > 1 }
429437
.keys
430438
.toList()
439+
.sorted()
431440

432441
if (exactDuplicates.isNotEmpty()) {
433442
throw GradleException(
@@ -444,6 +453,7 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
444453
.values
445454
.flatten()
446455
.distinct()
456+
.sorted()
447457

448458
if (caseInsensitiveDuplicates.isNotEmpty()) {
449459
throw GradleException(
@@ -471,6 +481,7 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
471481
.filter { it.value.size > 1 }
472482
.keys
473483
.toList()
484+
.sorted()
474485

475486
if (exactDuplicates.isNotEmpty()) {
476487
throw GradleException(
@@ -487,6 +498,7 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
487498
.values
488499
.flatten()
489500
.distinct()
501+
.sorted()
490502

491503
if (caseInsensitiveDuplicates.isNotEmpty()) {
492504
throw GradleException(

tools/gradle-plugin/src/test/kotlin/io/github/composegears/valkyrie/gradle/FailedConfigurationTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,8 @@ class FailedConfigurationTest : CommonGradleTest() {
348348

349349
val result = failTask(root, TASK_NAME)
350350
assertThat(result.output).contains("Found icon names that would collide on case-insensitive file systems")
351-
assertThat(result.output).contains("TestIcon, Testicon")
351+
assertThat(result.output).contains("TestIcon")
352+
assertThat(result.output).contains("Testicon")
352353
}
353354

354355
@Test
@@ -581,5 +582,7 @@ class FailedConfigurationTest : CommonGradleTest() {
581582
val result = failTask(root, TASK_NAME)
582583
assertThat(result.output).contains("Found icon names that would collide on case-insensitive file systems")
583584
assertThat(result.output).contains("ValkyrieIcons.Outlined")
585+
assertThat(result.output).contains("TestIcon")
586+
assertThat(result.output).contains("Testicon")
584587
}
585588
}

tools/idea-plugin/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- Add export as file action for `Simple mode` and `ImageVector to XML` tool
88
- Add validation for exact duplicate icon names (e.g., `test-icon.svg` and `test_icon.svg` both produce `TestIcon.kt`)
99
- Add validation for case-insensitive duplicate icon names to prevent file overwrites on macOS/Windows
10-
- Add automatic re-validation when `useFlatPackage` setting changes to immediately detect new conflicts
10+
- Add automatic re-validation when `useFlatPackage` setting changes to detect new conflicts immediately
1111

1212
### Fixed
1313

tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/jewel/textfield/ConfirmTextField.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.width
77
import androidx.compose.foundation.text.input.rememberTextFieldState
88
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
99
import androidx.compose.runtime.Composable
10+
import androidx.compose.runtime.LaunchedEffect
1011
import androidx.compose.runtime.derivedStateOf
1112
import androidx.compose.runtime.getValue
1213
import androidx.compose.runtime.remember
@@ -41,6 +42,13 @@ fun ConfirmTextField(
4142
val state = rememberTextFieldState(text)
4243
val focusManager = LocalFocusManager.current
4344

45+
// Update text field state when external text changes
46+
LaunchedEffect(text) {
47+
if (state.text.toString() != text) {
48+
state.setTextAndPlaceCursorAtEnd(text)
49+
}
50+
}
51+
4452
val isError by remember { derivedStateOf { state.text.isEmpty() } }
4553
var focused by rememberMutableState { false }
4654

tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/mode/iconpack/conversion/IconPackConversionViewModel.kt

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ class IconPackConversionViewModel(
148148
}
149149
copy(
150150
icons = updatedIcons,
151-
importIssues = updatedIcons.checkImportIssues(useFlatPackage = isFlatPackage)
151+
importIssues = updatedIcons.checkImportIssues(useFlatPackage = isFlatPackage),
152152
)
153153
}
154154
else -> this
@@ -271,35 +271,96 @@ class IconPackConversionViewModel(
271271
}
272272
}
273273

274-
val nameGroups = processedIcons.groupBy { it.iconName.name }
275-
val nameCounters = mutableMapOf<String, Int>()
276-
277-
val icons = processedIcons.map { icon ->
278-
val originalName = icon.iconName.name
279-
val group = nameGroups[originalName]
274+
// Group icons by their output location (considering useFlatPackage)
275+
val iconsByLocation = processedIcons.groupBy { icon ->
276+
when (val pack = icon.iconPack) {
277+
is IconPack.Single -> pack.iconPackName
278+
is IconPack.Nested -> when {
279+
isFlatPackage -> pack.iconPackName // All in same location when flat
280+
else -> "${pack.iconPackName}.${pack.currentNestedPack}" // Separate locations
281+
}
282+
}
283+
}
280284

281-
if (group != null && group.size > 1) {
282-
val counter = nameCounters.getOrDefault(originalName, 0) + 1
283-
nameCounters[originalName] = counter
285+
// Process duplicates within each location group
286+
val resolvedIcons = iconsByLocation.flatMap { (_, iconsInLocation) ->
287+
// Track all committed names to ensure uniqueness across both passes
288+
val committedNames = mutableSetOf<String>()
289+
290+
// First, resolve exact duplicates
291+
val nameGroups = iconsInLocation.groupBy { it.iconName.name }
292+
val nameCounters = mutableMapOf<String, Int>()
293+
294+
val iconsWithResolvedExactDuplicates = iconsInLocation.map { icon ->
295+
val originalName = icon.iconName.name
296+
val group = nameGroups[originalName]
297+
298+
if (group != null && group.size > 1) {
299+
val counter = nameCounters.getOrDefault(originalName, 0) + 1
300+
nameCounters[originalName] = counter
301+
302+
if (counter > 1) {
303+
// Generate unique name by incrementing suffix until not in committedNames
304+
var suffix = counter - 1
305+
var candidateName = "$originalName$suffix"
306+
while (committedNames.any { it.equals(candidateName, ignoreCase = true) }) {
307+
suffix++
308+
candidateName = "$originalName$suffix"
309+
}
310+
committedNames.add(candidateName)
311+
icon.copy(iconName = IconName(candidateName))
312+
} else {
313+
committedNames.add(originalName)
314+
icon
315+
}
316+
} else {
317+
committedNames.add(originalName)
318+
icon
319+
}
320+
}
284321

285-
if (counter > 1) {
286-
icon.copy(iconName = IconName("$originalName${counter - 1}"))
322+
// Then, resolve case-insensitive duplicates
323+
val lowercaseGroups = iconsWithResolvedExactDuplicates.groupBy { it.iconName.name.lowercase() }
324+
val lowercaseCounters = mutableMapOf<String, Int>()
325+
326+
iconsWithResolvedExactDuplicates.map { icon ->
327+
val currentName = icon.iconName.name
328+
val lowercaseKey = currentName.lowercase()
329+
val group = lowercaseGroups[lowercaseKey]
330+
331+
// Only process if there are multiple icons with same lowercase name but different actual names
332+
if (group != null && group.size > 1 && group.map { it.iconName.name }.distinct().size > 1) {
333+
val counter = lowercaseCounters.getOrDefault(lowercaseKey, 0) + 1
334+
lowercaseCounters[lowercaseKey] = counter
335+
336+
if (counter > 1) {
337+
// Generate unique name by incrementing suffix until not in committedNames
338+
var suffix = counter - 1
339+
var candidateName = "$currentName$suffix"
340+
while (committedNames.any { it.equals(candidateName, ignoreCase = true) }) {
341+
suffix++
342+
candidateName = "$currentName$suffix"
343+
}
344+
committedNames.add(candidateName)
345+
icon.copy(iconName = IconName(candidateName))
346+
} else {
347+
// First in group - name is already in committedNames from exact duplicate pass
348+
icon
349+
}
287350
} else {
288351
icon
289352
}
290-
} else {
291-
icon
292353
}
293354
}
294355

295-
if (icons.isEmpty()) {
356+
if (resolvedIcons.isEmpty()) {
296357
_events.emit(ConversionEvent.NothingToImport)
297358
reset()
298359
} else {
299360
_state.updateState {
300361
creationState.copy(
301-
icons = icons,
302-
importIssues = icons.checkImportIssues(useFlatPackage = isFlatPackage),
362+
icons = resolvedIcons,
363+
importIssues = resolvedIcons.checkImportIssues(useFlatPackage = isFlatPackage),
303364
)
304365
}
305366
}

tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/mode/iconpack/conversion/ui/util/IconValidation.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ fun List<BatchIcon>.checkImportIssues(useFlatPackage: Boolean = false): Map<Vali
4949
val packIdentifier = when (val pack = it.iconPack) {
5050
is IconPack.Single -> pack.iconPackName
5151
is IconPack.Nested -> when {
52-
useFlatPackage -> pack.iconPackName // Flat package: check across all nested packs
53-
else -> "${pack.iconPackName}.${pack.currentNestedPack}" // Separate folders per nested pack
52+
useFlatPackage -> pack.iconPackName // Flat package: check across all nested packs
53+
else -> "${pack.iconPackName}.${pack.currentNestedPack}" // Separate folders per nested pack
5454
}
5555
}
5656
packIdentifier to it.iconName.name
@@ -60,6 +60,7 @@ fun List<BatchIcon>.checkImportIssues(useFlatPackage: Boolean = false): Map<Vali
6060
.flatten()
6161
.map { it.iconName }
6262
.distinct()
63+
.sortedBy { it.name }
6364

6465
addIfNotEmpty(error = HasDuplicates, icons = duplicates)
6566

@@ -72,22 +73,22 @@ fun List<BatchIcon>.checkImportIssues(useFlatPackage: Boolean = false): Map<Vali
7273
val packIdentifier = when (val pack = it.iconPack) {
7374
is IconPack.Single -> pack.iconPackName
7475
is IconPack.Nested -> when {
75-
useFlatPackage -> pack.iconPackName // Flat package: check across all nested packs
76-
else -> "${pack.iconPackName}.${pack.currentNestedPack}" // Separate folders per nested pack
76+
useFlatPackage -> pack.iconPackName // Flat package: check across all nested packs
77+
else -> "${pack.iconPackName}.${pack.currentNestedPack}" // Separate folders per nested pack
7778
}
7879
}
7980
packIdentifier to it.iconName.name.lowercase()
8081
}
8182
.filter {
8283
// Filter groups where there are multiple icons with the same lowercase name
8384
// but they are not already exact duplicates
84-
it.value.size > 1 &&
85-
it.value.map { icon -> icon.iconName.name }.distinct().size > 1
85+
it.value.size > 1 && it.value.map { icon -> icon.iconName.name }.distinct().size > 1
8686
}
8787
.values
8888
.flatten()
8989
.map { it.iconName }
9090
.distinct()
91+
.sortedBy { it.name }
9192

9293
addIfNotEmpty(error = HasCaseInsensitiveDuplicates, icons = caseInsensitiveDuplicates)
9394
}

0 commit comments

Comments
 (0)