diff --git a/src/main/kotlin/com/z8dn/plugins/a2pt/settings/AndroidViewSettingsConfigurable.kt b/src/main/kotlin/com/z8dn/plugins/a2pt/settings/AndroidViewSettingsConfigurable.kt index f51bc23..e6ca559 100644 --- a/src/main/kotlin/com/z8dn/plugins/a2pt/settings/AndroidViewSettingsConfigurable.kt +++ b/src/main/kotlin/com/z8dn/plugins/a2pt/settings/AndroidViewSettingsConfigurable.kt @@ -2,9 +2,16 @@ package com.z8dn.plugins.a2pt.settings import com.z8dn.plugins.a2pt.AndroidViewBundle +import com.intellij.icons.AllIcons import com.intellij.ide.projectView.ProjectView +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep import com.intellij.ui.ToolbarDecorator import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel @@ -74,6 +81,13 @@ class AndroidViewSettingsConfigurable : SearchableConfigurable { .setAddAction { addGroup() } .setRemoveAction { removeGroup() } .setEditAction { editGroup() } + .addExtraAction(object : AnAction( + AndroidViewBundle.message("settings.LoadPreset.text"), + AndroidViewBundle.message("settings.LoadPreset.description"), + AllIcons.Actions.Download + ) { + override fun actionPerformed(e: AnActionEvent) = loadPreset(e.dataContext) + }) panel.add(decorator.createPanel(), BorderLayout.CENTER) @@ -108,6 +122,47 @@ class AndroidViewSettingsConfigurable : SearchableConfigurable { } } + /** + * Prompts the user to pick a preset, then a merge mode, and applies the result to the table. + */ + private fun loadPreset(dataContext: DataContext) { + val presetStep = object : BaseListPopupStep( + AndroidViewBundle.message("settings.LoadPreset.ChoosePreset.text"), + FileGroupPresets.ALL + ) { + override fun getTextFor(value: FileGroupPreset): String = value.displayName + + override fun onChosen(selectedValue: FileGroupPreset, finalChoice: Boolean): PopupStep<*>? { + return doFinalStep { chooseMergeMode(selectedValue, dataContext) } + } + } + JBPopupFactory.getInstance().createListPopup(presetStep).showInBestPositionFor(dataContext) + } + + private fun chooseMergeMode(preset: FileGroupPreset, dataContext: DataContext) { + val modes = listOf( + PresetMergeMode.APPEND_SKIP_DUPLICATES to AndroidViewBundle.message("settings.LoadPreset.Mode.Append.text"), + PresetMergeMode.OVERWRITE_DUPLICATES to AndroidViewBundle.message("settings.LoadPreset.Mode.Overwrite.text"), + PresetMergeMode.REPLACE_ALL to AndroidViewBundle.message("settings.LoadPreset.Mode.Replace.text") + ) + val modeStep = object : BaseListPopupStep>( + AndroidViewBundle.message("settings.LoadPreset.ChooseMode.text"), + modes + ) { + override fun getTextFor(value: Pair): String = value.second + + override fun onChosen(selectedValue: Pair, finalChoice: Boolean): PopupStep<*>? { + return doFinalStep { applyPreset(preset, selectedValue.first) } + } + } + JBPopupFactory.getInstance().createListPopup(modeStep).showInBestPositionFor(dataContext) + } + + private fun applyPreset(preset: FileGroupPreset, mode: PresetMergeMode) { + val current = groupsTableModel?.getGroups() ?: emptyList() + groupsTableModel?.setGroups(FileGroupPresets.merge(current, preset, mode)) + } + override fun isModified(): Boolean { val settings = AndroidViewSettings.getInstance() diff --git a/src/main/kotlin/com/z8dn/plugins/a2pt/settings/FileGroupPresets.kt b/src/main/kotlin/com/z8dn/plugins/a2pt/settings/FileGroupPresets.kt new file mode 100644 index 0000000..1ca8518 --- /dev/null +++ b/src/main/kotlin/com/z8dn/plugins/a2pt/settings/FileGroupPresets.kt @@ -0,0 +1,137 @@ +package com.z8dn.plugins.a2pt.settings + +import com.z8dn.plugins.a2pt.AndroidViewBundle + +/** + * A named, predefined set of [ProjectFileGroup]s for a common project stack. + * + * Presets ship with the plugin and can be loaded into the Custom File Groups list from the + * settings page. Loading a preset produces ordinary [ProjectFileGroup] instances that remain + * freely editable afterwards. + */ +data class FileGroupPreset( + val displayName: String, + val groups: List +) + +/** + * How a preset should be merged into the existing list of file groups. + */ +enum class PresetMergeMode { + /** Add the preset's groups, skipping any whose name already exists (case-insensitive). */ + APPEND_SKIP_DUPLICATES, + + /** Add the preset's groups, replacing the patterns of any existing group with the same name. */ + OVERWRITE_DUPLICATES, + + /** Discard the existing groups entirely and use only the preset's groups. */ + REPLACE_ALL +} + +/** + * Built-in file-group presets for common stacks. + */ +object FileGroupPresets { + + private fun group(name: String, vararg patterns: String): ProjectFileGroup = + ProjectFileGroup(groupName = name, patterns = patterns.toMutableList()) + + /** Standard Android project structure. */ + private fun androidPreset(): FileGroupPreset = FileGroupPreset( + displayName = AndroidViewBundle.message("preset.Android.text"), + groups = listOf( + group( + "Gradle Scripts", + "build.gradle", "build.gradle.kts", + "settings.gradle", "settings.gradle.kts", + "gradle.properties", "libs.versions.toml", "*.versions.toml" + ), + group( + "Android Config", + "AndroidManifest.xml", "proguard-rules.pro", "consumer-rules.pro", "*.pro" + ), + group( + "Documentation", + "*.md", "LICENSE", "NOTICE", "*.txt" + ) + ) + ) + + /** Kotlin Multiplatform (KMP) project structure. */ + private fun kmpPreset(): FileGroupPreset = FileGroupPreset( + displayName = AndroidViewBundle.message("preset.Kmp.text"), + groups = listOf( + group( + "Gradle Scripts", + "*.gradle.kts", "gradle.properties", "libs.versions.toml" + ), + group( + "Native Configs", + "*.def", "*.podspec", "Podfile", "*.xcconfig" + ), + group( + "Platform Metadata", + "*.toml", "*.properties" + ) + ) + ) + + /** Compose Multiplatform project structure. */ + private fun composePreset(): FileGroupPreset = FileGroupPreset( + displayName = AndroidViewBundle.message("preset.Compose.text"), + groups = listOf( + group( + "Compose Config", + "*.gradle.kts", "compose.*", "gradle.properties" + ), + group( + "Compose Resources", + "composeResources/*", "*.xml", "*.cdr" + ) + ) + ) + + /** All presets shipped with the plugin, in display order. */ + val ALL: List + get() = listOf(androidPreset(), kmpPreset(), composePreset()) + + /** + * Merges [preset] into [existing] according to [mode], returning a new list. + * Group name comparison is case-insensitive. + */ + fun merge( + existing: List, + preset: FileGroupPreset, + mode: PresetMergeMode + ): List { + fun copy(g: ProjectFileGroup) = ProjectFileGroup(g.groupName, g.patterns.toMutableList()) + + return when (mode) { + PresetMergeMode.REPLACE_ALL -> preset.groups.map { copy(it) } + + PresetMergeMode.APPEND_SKIP_DUPLICATES -> { + val existingNames = existing.map { it.groupName.lowercase() }.toMutableSet() + val result = existing.map { copy(it) }.toMutableList() + preset.groups.forEach { g -> + if (existingNames.add(g.groupName.lowercase())) { + result.add(copy(g)) + } + } + result + } + + PresetMergeMode.OVERWRITE_DUPLICATES -> { + val result = existing.map { copy(it) }.toMutableList() + preset.groups.forEach { g -> + val index = result.indexOfFirst { it.groupName.equals(g.groupName, ignoreCase = true) } + if (index >= 0) { + result[index] = copy(g) + } else { + result.add(copy(g)) + } + } + result + } + } + } +} diff --git a/src/main/resources/messages/AndroidViewBundle.properties b/src/main/resources/messages/AndroidViewBundle.properties index f5f98c6..c966209 100644 --- a/src/main/resources/messages/AndroidViewBundle.properties +++ b/src/main/resources/messages/AndroidViewBundle.properties @@ -14,6 +14,18 @@ settings.CustomFileGroups.text=Custom File Groups settings.CustomFileGroups.description=Each group can contain multiple file patterns (e.g., *.md, LICENSE, README.md). Use ! to exclude files (e.g., !archive/*.md). settings.Table.ColumnName.groupName=Group Name settings.Table.ColumnName.patterns=Patterns +settings.LoadPreset.text=Load Preset +settings.LoadPreset.description=Add predefined file groups for a common project stack +settings.LoadPreset.ChoosePreset.text=Choose a Preset +settings.LoadPreset.ChooseMode.text=How to Apply Preset +settings.LoadPreset.Mode.Append.text=Append (skip duplicate names) +settings.LoadPreset.Mode.Overwrite.text=Overwrite groups with the same name +settings.LoadPreset.Mode.Replace.text=Replace all existing groups + +# Presets +preset.Android.text=Standard Android +preset.Kmp.text=Kotlin Multiplatform +preset.Compose.text=Compose Multiplatform # Project File Group Dialog dialog.ProjectFileGroup.Add.text=Add Custom File Group diff --git a/src/test/kotlin/com/z8dn/plugins/a2pt/settings/FileGroupPresetsTest.kt b/src/test/kotlin/com/z8dn/plugins/a2pt/settings/FileGroupPresetsTest.kt new file mode 100644 index 0000000..8903d19 --- /dev/null +++ b/src/test/kotlin/com/z8dn/plugins/a2pt/settings/FileGroupPresetsTest.kt @@ -0,0 +1,95 @@ +package com.z8dn.plugins.a2pt.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FileGroupPresetsTest { + + private fun preset(name: String, vararg groups: ProjectFileGroup) = + FileGroupPreset(name, groups.toList()) + + private fun group(name: String, vararg patterns: String) = + ProjectFileGroup(name, patterns.toMutableList()) + + // region preset content + + @Test + fun `every shipped preset has at least one group`() { + FileGroupPresets.ALL.forEach { p -> + assertTrue("preset '${p.displayName}' should have groups", p.groups.isNotEmpty()) + } + } + + @Test + fun `every group has a name and at least one inclusion pattern`() { + FileGroupPresets.ALL.forEach { p -> + p.groups.forEach { g -> + assertTrue("group name should not be blank in '${p.displayName}'", g.groupName.isNotBlank()) + assertTrue("group '${g.groupName}' should have patterns", g.patterns.isNotEmpty()) + assertTrue( + "group '${g.groupName}' should have at least one inclusion pattern", + g.patterns.any { !it.startsWith("!") } + ) + } + } + } + + @Test + fun `group names are unique within each preset`() { + FileGroupPresets.ALL.forEach { p -> + val names = p.groups.map { it.groupName.lowercase() } + assertEquals("group names should be unique in '${p.displayName}'", names.size, names.toSet().size) + } + } + + // endregion + + // region merge + + @Test + fun `replace all discards existing groups`() { + val existing = listOf(group("Existing", "*.kt")) + val p = preset("P", group("A", "*.md")) + + val result = FileGroupPresets.merge(existing, p, PresetMergeMode.REPLACE_ALL) + + assertEquals(listOf("A"), result.map { it.groupName }) + } + + @Test + fun `append skips groups whose name already exists case-insensitively`() { + val existing = listOf(group("Docs", "*.txt")) + val p = preset("P", group("docs", "*.md"), group("New", "*.kt")) + + val result = FileGroupPresets.merge(existing, p, PresetMergeMode.APPEND_SKIP_DUPLICATES) + + assertEquals(listOf("Docs", "New"), result.map { it.groupName }) + // existing 'Docs' patterns preserved, not overwritten + assertEquals(mutableListOf("*.txt"), result.first { it.groupName == "Docs" }.patterns) + } + + @Test + fun `overwrite replaces patterns of duplicate names and appends the rest`() { + val existing = listOf(group("Docs", "*.txt")) + val p = preset("P", group("docs", "*.md"), group("New", "*.kt")) + + val result = FileGroupPresets.merge(existing, p, PresetMergeMode.OVERWRITE_DUPLICATES) + + assertEquals(listOf("Docs", "New"), result.map { it.groupName }) + assertEquals(mutableListOf("*.md"), result.first { it.groupName == "Docs" }.patterns) + } + + @Test + fun `merge does not mutate the source lists`() { + val existing = listOf(group("Docs", "*.txt")) + val p = preset("P", group("docs", "*.md")) + + FileGroupPresets.merge(existing, p, PresetMergeMode.OVERWRITE_DUPLICATES) + + assertEquals(mutableListOf("*.txt"), existing.first().patterns) + assertEquals(mutableListOf("*.md"), p.groups.first().patterns) + } + + // endregion +}