diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8814485 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(tail:*)", + "Bash(grep:*)", + "Bash(grep)", + "Bash(chmod +x)", + "Bash(lsof)", + "Bash(grep*)", + "Bash(jar tf:*)", + "Bash(head:*)", + "Bash(./gradlew*)" + ] + } +} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000..5f54c49 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,115 @@ +name: UI Tests + +on: + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + ui-test: + strategy: + # Keep running the other platforms even if one fails so we get full signal + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Pre-download IDE and build plugin so the background launch is fast and doesn't fail silently. + - name: Build plugin and prepare sandbox + run: ./gradlew prepareSandbox_runIdeForUiTests + + # Linux runners have no display server; start a virtual one before launching the IDE. + - name: Start virtual display (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get install -y xvfb + Xvfb :99 -screen 0 1920x1080x24 & + sleep 2 + echo "DISPLAY=:99" >> "$GITHUB_ENV" + + - name: Clean up test module (pre-test) + run: rm -rf src/uiTest/testProject/repository + shell: bash + + # Run IDE + tests in a single step so the backgrounded IDE process stays alive. + - name: Start IDE, wait for robot server, and run UI tests + shell: bash + env: + DISPLAY: ${{ env.DISPLAY }} + run: | + echo "Starting IDE with robot server..." + ./gradlew runIdeForUiTests > ide-output.log 2>&1 & + IDE_PID=$! + echo "IDE PID: $IDE_PID" + + # Poll until the robot server responds to HTTP requests. + echo "Waiting for robot server on port 8082..." + MAX_WAIT=90 + ATTEMPT=0 + until curl -sf --connect-timeout 2 http://127.0.0.1:8082/api/about > /dev/null 2>&1; do + ATTEMPT=$((ATTEMPT + 1)) + if [ "$ATTEMPT" -ge "$MAX_WAIT" ]; then + echo "ERROR: robot server did not start after $((MAX_WAIT * 5)) seconds" + echo "=== IDE output (last 100 lines) ===" + tail -100 ide-output.log 2>/dev/null || true + exit 1 + fi + echo " attempt $ATTEMPT/$MAX_WAIT — not ready yet, retrying in 5s..." + sleep 5 + done + echo "Robot server is ready!" + + # Run the UI tests + ./gradlew uiTest || TEST_EXIT=$? + + echo "=== IDE output (last 50 lines) ===" + tail -50 ide-output.log 2>/dev/null || true + + exit ${TEST_EXIT:-0} + + - name: Clean up test module (post-test) + if: always() + run: | + rm -rf src/uiTest/testProject/repository + git checkout -- src/uiTest/testProject/settings.gradle.kts + shell: bash + + - name: Upload IDE output log + if: always() + uses: actions/upload-artifact@v4 + with: + name: ide-output-${{ matrix.os }} + path: ide-output.log + if-no-files-found: ignore + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-test-results-${{ matrix.os }} + path: build/reports/tests/uiTest/ + if-no-files-found: ignore + + # Upload IDE logs to help diagnose failures + - name: Upload IDE logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ide-logs-${{ matrix.os }} + path: build/idea-sandbox/system/log/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 60428c3..88ccfad 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ google-services.json .DS_Store .intellijPlatform + +.kotlin +.claude diff --git a/CHANGELOG.md b/CHANGELOG.md index 74bbe69..2347650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Module Maker Changelog -## [1.1.2] +## [1.2.0] - Fix startup issue on Windows +- Change to standard IntelliJ theming ## [1.1.1] - Platform updates diff --git a/build.gradle.kts b/build.gradle.kts index 5c15e71..ca5e6f5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,12 +7,11 @@ fun environment(key: String) = providers.environmentVariable(key) plugins { id("java") // Java support alias(libs.plugins.kotlin) // Kotlin support - alias(libs.plugins.gradleIntelliJPlugin) // Gradle IntelliJ Plugin + alias(libs.plugins.gradleIntelliJPlugin) // IntelliJ Platform Gradle Plugin alias(libs.plugins.changelog) // Gradle Changelog Plugin - kotlin("plugin.serialization") version libs.versions.kotlin.get() - id("org.jetbrains.compose") + alias(libs.plugins.compose) // Gradle Compose Compiler Plugin alias(libs.plugins.spotless) - alias(libs.plugins.compose) + kotlin("plugin.serialization") version libs.versions.kotlin.get() } group = properties("pluginGroup").get() @@ -38,36 +37,36 @@ repositories { intellijPlatform { defaultRepositories() } + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies") } apply( from = "gradle/spotless.gradle" ) +sourceSets { + create("uiTest") { + kotlin.srcDir("src/uiTest/kotlin") + } +} + dependencies { implementation(libs.freemarker) implementation(libs.serialization) - implementation(compose.desktop.currentOs) - implementation(compose.materialIconsExtended) implementation(libs.segment) - // I usually do - // ./gradlew dependencies | grep "skiko" - // to get the skiko version that compose depends on - val version = "0.9.37.4" - val macTarget = "macos-arm64" - val windowsTarget = "windows-x64" - val linuxTarget = "linux-x64" - - implementation("org.jetbrains.skiko:skiko-awt-runtime-$macTarget:$version") - implementation("org.jetbrains.skiko:skiko-awt-runtime-$windowsTarget:$version") - implementation("org.jetbrains.skiko:skiko-awt-runtime-$linuxTarget:$version") - testImplementation(libs.junit) + "uiTestImplementation"(kotlin("stdlib")) + "uiTestImplementation"(libs.remoteRobot) + "uiTestImplementation"(libs.remoteRobotFixtures) + "uiTestImplementation"(libs.junit) + "uiTestImplementation"("com.squareup.okhttp3:okhttp:4.12.0") + // The Compose compiler plugin applies to all source sets; uiTest needs the runtime on its classpath + "uiTestImplementation"("org.jetbrains.compose.runtime:runtime-desktop:1.7.3") + // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - javaCompiler("243.26053.29") // https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1894 create(properties("platformType").get(), properties("platformVersion").get()) // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. @@ -76,6 +75,10 @@ dependencies { // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file for plugin from JetBrains Marketplace. plugins(properties("platformPlugins").map { it.split(',') }) + // Compose support dependencies + @Suppress("UnstableApiUsage") + composeUI() + // instrumentationTools() pluginVerifier() zipSigner() @@ -136,6 +139,24 @@ tasks { } } +tasks.register("uiTest") { + description = "Runs UI tests against a running IDE instance" + group = "verification" + testClassesDirs = sourceSets["uiTest"].output.classesDirs + classpath = sourceSets["uiTest"].runtimeClasspath + systemProperty("robot-server.port", System.getProperty("robot-server.port", "8082")) + doNotTrackState("UI tests are not cacheable") + // Gson (used by the remote-robot client) reflectively accesses private fields such as + // Throwable.detailMessage when deserializing error responses from the robot server. + // JDK 17+ JPMS blocks this by default; --add-opens restores access. + jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED") + // Echo test stdout/stderr directly to the Gradle console so diagnostic println calls + // (accessibility tree dumps, screenshots, component class lists) are visible live. + testLogging { + showStandardStreams = true + } +} + intellijPlatformTesting { runIde { register("runIdeForUiTests") { @@ -145,9 +166,24 @@ intellijPlatformTesting { "-Drobot-server.port=8082", "-Dide.mac.message.dialogs.as.sheets=false", "-Djb.privacy.policy.text=", - "-Djb.consents.confirmation.enabled=false" + "-Djb.consents.confirmation.enabled=false", + // Skip the "Trust Project?" dialog that blocks the IDE frame from appearing + "-Didea.trust.all.projects=true", + "-Didea.initially.ask.config=never", + // Suppress Tip of the Day, What's New, and other first-run dialogs + "-Dide.show.tips.on.startup.default.value=false", + "-Didea.is.internal=false", + "-Dide.no.platform.update=true", + // Skip import settings dialog + "-Didea.config.imported.in.current.session=true", + // Force the Swing menu bar so remote-robot can find menu items. + // Without this, macOS uses the native system menu bar which is + // invisible to the Swing component hierarchy that remote-robot inspects. + "-Dapple.laf.useScreenMenuBar=false" ) } + // Open the test project so settings.gradle.kts is available for module creation + args(layout.projectDirectory.dir("src/uiTest/testProject").asFile.absolutePath) } plugins { diff --git a/gradle.properties b/gradle.properties index bb39734..e3c76bb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.joetr.modulemaker pluginName = ModuleMaker pluginRepositoryUrl = https://github.com/j-roskopf/ModuleMakerPlugin # SemVer format -> https://semver.org -pluginVersion = 1.1.2 +pluginVersion = 1.2.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 222 @@ -39,4 +39,3 @@ org.gradle.caching = true # Enable Gradle Kotlin DSL Lazy Property Assignment -> https://docs.gradle.org/current/userguide/kotlin_dsl.html#kotdsl:assignment systemProp.org.gradle.unsafe.kotlin.assignment = true -compose.version=1.10.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb0130a..954566d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,18 +2,21 @@ freemarker = "2.3.30" serialization = "1.5.1" jdk = "17" -kotlin = "2.3.0" +kotlin = "2.1.20" changelog = "2.0.0" -gradleIntelliJPlugin = "2.11.0" +gradleIntelliJPlugin = "2.10.5" spotless = "6.8.0" segment = "1.13.2" junit = "4.13.2" +remoteRobot = "0.11.23" [libraries] freemarker = { group = "org.freemarker", name = "freemarker", version.ref = "freemarker" } serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } segment = { group = "com.segment.analytics.kotlin", name = "core", version.ref = "segment" } junit = { group = "junit", name = "junit", version.ref = "junit" } +remoteRobot = { group = "com.intellij.remoterobot", name = "remote-robot", version.ref = "remoteRobot" } +remoteRobotFixtures = { group = "com.intellij.remoterobot", name = "remote-fixtures", version.ref = "remoteRobot" } [plugins] changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } diff --git a/run-ui-tests.sh b/run-ui-tests.sh new file mode 100755 index 0000000..ae22eae --- /dev/null +++ b/run-ui-tests.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Start the IDE with the robot server in the background. +# runIdeForUiTests never exits on its own, so we launch it as a background process +# and kill it when the tests finish. +echo "Starting IDE with robot server..." +./gradlew runIdeForUiTests & +IDE_PID=$! + +cleanup() { + echo "Stopping IDE (pid $IDE_PID)..." + kill "$IDE_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for the robot server TCP port to accept connections (up to 5 minutes). +# The server has no GET health endpoint; nc is the reliable way to probe the port. +echo "Waiting for robot server on port 8082..." +MAX_ATTEMPTS=60 +ATTEMPT=0 +until nc -z localhost 8082 2>/dev/null; do + ATTEMPT=$((ATTEMPT + 1)) + if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; then + echo "ERROR: robot server did not start after $((MAX_ATTEMPTS * 5)) seconds." + exit 1 + fi + echo " attempt $ATTEMPT/$MAX_ATTEMPTS — not ready yet, retrying in 5s..." + sleep 5 +done +echo "IDE is ready!" + +# Clean up any previously created test module before running. +TEST_MODULE_DIR="src/uiTest/testProject/repository" +rm -rf "$TEST_MODULE_DIR" + +# Run the tests. The EXIT trap above will kill the IDE when this exits. +./gradlew uiTest +TEST_EXIT=$? + +# Clean up the created test module and restore settings.gradle.kts after running. +rm -rf "$TEST_MODULE_DIR" +git checkout -- src/uiTest/testProject/settings.gradle.kts + +exit $TEST_EXIT diff --git a/settings.gradle.kts b/settings.gradle.kts index 8236435..2d00d47 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1 @@ rootProject.name = "Module Maker" - -pluginManagement { - plugins { - id("org.jetbrains.compose").version(extra["compose.version"] as String) - } -} diff --git a/src/main/kotlin/com/joetr/modulemaker/ModuleMakerDialogWrapper.kt b/src/main/kotlin/com/joetr/modulemaker/ModuleMakerDialogWrapper.kt index b4f977f..7f0472b 100644 --- a/src/main/kotlin/com/joetr/modulemaker/ModuleMakerDialogWrapper.kt +++ b/src/main/kotlin/com/joetr/modulemaker/ModuleMakerDialogWrapper.kt @@ -5,32 +5,24 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.RadioButton -import androidx.compose.material.RadioButtonDefaults -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.intellij.icons.AllIcons import com.intellij.openapi.externalSystem.model.ProjectSystemId import com.intellij.openapi.externalSystem.service.execution.ProgressExecutionMode import com.intellij.openapi.externalSystem.util.ExternalSystemUtil @@ -49,6 +41,15 @@ import com.joetr.modulemaker.ui.file.FileTreeView import com.joetr.modulemaker.ui.theme.WidgetTheme import com.segment.analytics.kotlin.core.Analytics import org.jetbrains.annotations.Nullable +import org.jetbrains.jewel.bridge.JewelComposeNoThemePanel +import org.jetbrains.jewel.bridge.icon.fromPlatformIcon +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.RadioButtonRow +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.icon.IntelliJIconKey import java.awt.event.ActionEvent import java.io.File import java.nio.file.Path @@ -88,8 +89,8 @@ class ModuleMakerDialogWrapper( private val addGitIgnore = mutableStateOf(preferenceService.preferenceState.addGitIgnore) private val moduleTypeSelection = mutableStateOf(ANDROID) private val platformTypeSelection = mutableStateOf(ANDROID) - private val moduleName = mutableStateOf("") - private val packageName = mutableStateOf(preferenceService.preferenceState.packageName) + private val moduleName = mutableStateOf(TextFieldValue("")) + private val packageName = mutableStateOf(TextFieldValue(preferenceService.preferenceState.packageName)) private val sourceSets = mutableStateListOf() // Segment's write key isn't really a secret @@ -113,26 +114,22 @@ class ModuleMakerDialogWrapper( } } + @OptIn(ExperimentalJewelApi::class) @Nullable override fun createCenterPanel(): JComponent { - return ComposePanel().apply { - setBounds(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT) - setContent { - WidgetTheme { - Surface { - Row { - val startingHeight = remember { mutableStateOf(WINDOW_HEIGHT) } - val fileTreeWidth = remember { mutableStateOf(FILE_TREE_WIDTH) } - val configurationPanelWidth = remember { mutableStateOf(CONFIGURATION_PANEL_WIDTH) } - FileTreeJPanel( - modifier = Modifier.height(startingHeight.value.dp).width(fileTreeWidth.value.dp) - ) - ConfigurationPanel( - modifier = Modifier.height(startingHeight.value.dp) - .width(configurationPanelWidth.value.dp) - ) - } - } + return JewelComposeNoThemePanel(focusOnClickInside = true) { + WidgetTheme { + Row { + val startingHeight = remember { mutableStateOf(WINDOW_HEIGHT) } + val fileTreeWidth = remember { mutableStateOf(FILE_TREE_WIDTH) } + val configurationPanelWidth = remember { mutableStateOf(CONFIGURATION_PANEL_WIDTH) } + FileTreeJPanel( + modifier = Modifier.height(startingHeight.value.dp).width(fileTreeWidth.value.dp) + ) + ConfigurationPanel( + modifier = Modifier.height(startingHeight.value.dp) + .width(configurationPanelWidth.value.dp) + ) } } } @@ -154,7 +151,7 @@ class ModuleMakerDialogWrapper( } private fun onSettingsSaved() { - packageName.value = preferenceService.preferenceState.packageName + packageName.value = TextFieldValue(preferenceService.preferenceState.packageName) threeModuleCreation.value = preferenceService.preferenceState.threeModuleCreationDefault useKtsExtension.value = preferenceService.preferenceState.useKtsFileExtension gradleFileNamedAfterModule.value = preferenceService.preferenceState.gradleFileNamedAfterModule @@ -195,7 +192,10 @@ class ModuleMakerDialogWrapper( } private fun validateInput(): Boolean { - return packageName.value.isNotEmpty() && selectedSrcValue.value != DEFAULT_SRC_VALUE && moduleName.value.isNotEmpty() && moduleName.value != DEFAULT_MODULE_NAME + return packageName.value.text.isNotEmpty() && + selectedSrcValue.value != DEFAULT_SRC_VALUE && + moduleName.value.text.isNotEmpty() && + moduleName.value.text != DEFAULT_MODULE_NAME } @Composable @@ -203,9 +203,10 @@ class ModuleMakerDialogWrapper( modifier: Modifier = Modifier ) { val height = remember { mutableStateOf(WINDOW_HEIGHT) } + val fileTree = remember { FileTree(root = File(rootDirectoryString()).toProjectFile()) } FileTreeView( modifier = modifier, - model = FileTree(root = File(rootDirectoryString()).toProjectFile()), + model = fileTree, height = height.value.dp, onClick = { fileTreeNode -> @@ -230,7 +231,7 @@ class ModuleMakerDialogWrapper( ) } - @OptIn(ExperimentalLayoutApi::class) + @OptIn(ExperimentalLayoutApi::class, ExperimentalJewelApi::class) @Composable private fun ConfigurationPanel( modifier: Modifier = Modifier @@ -239,6 +240,8 @@ class ModuleMakerDialogWrapper( val selectedRootState = remember { selectedSrcValue } Text("Selected root: ${selectedRootState.value}") + Spacer(Modifier.height(16.dp)) + Row { val threeModuleCreationState = remember { threeModuleCreation } LabelledCheckbox( @@ -256,15 +259,18 @@ class ModuleMakerDialogWrapper( More info can be found here https://www.droidcon.com/2019/11/15/android-at-scale-square/ """.trimIndent() ).show() - }, content = { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = "info", - modifier = Modifier.padding(end = 4.dp) - ) - }) + }) { _ -> + Icon( + key = IntelliJIconKey.fromPlatformIcon(AllIcons.General.Information), + contentDescription = "info", + iconClass = AllIcons::class.java, + modifier = Modifier.padding(end = 4.dp) + ) + } } + Spacer(Modifier.height(8.dp)) + val useKtsExtensionState = remember { useKtsExtension } LabelledCheckbox( label = "Use .kts file extension", @@ -274,6 +280,8 @@ class ModuleMakerDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val gradleFileNamedAfterModuleState = remember { gradleFileNamedAfterModule } LabelledCheckbox( label = "Gradle file named after module", @@ -283,6 +291,8 @@ class ModuleMakerDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val addReadmeState = remember { addReadme } LabelledCheckbox( label = "Add README.md", @@ -292,6 +302,8 @@ class ModuleMakerDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val addGitIgnoreState = remember { addGitIgnore } LabelledCheckbox( label = "Add .gitignore", @@ -301,69 +313,42 @@ class ModuleMakerDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val radioOptions = listOf(ANDROID, KOTLIN) val moduleTypeSelectionState = remember { moduleTypeSelection } Column { Text("Module Type") + Spacer(Modifier.height(8.dp)) radioOptions.forEach { text -> - Row( - modifier = Modifier.selectable( - selected = (text == moduleTypeSelectionState.value), - onClick = { - moduleTypeSelectionState.value = text - } - ).padding(end = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colors.primary, - unselectedColor = MaterialTheme.colors.primaryVariant - ), - selected = (text == moduleTypeSelectionState.value), - onClick = { - moduleTypeSelectionState.value = text - } - ) - Text( - text = text, - style = MaterialTheme.typography.body1.merge(), - modifier = Modifier.padding(start = 8.dp) - ) - } + RadioButtonRow( + text = text, + selected = (text == moduleTypeSelectionState.value), + onClick = { moduleTypeSelectionState.value = text }, + modifier = Modifier.padding(end = 16.dp) + ) + Spacer(Modifier.height(8.dp)) } } + Spacer(Modifier.height(8.dp)) + val platformTypeRadioOptions = listOf(ANDROID, MULTIPLATFORM) val platformTypeRadioOptionsState = remember { platformTypeSelection } Column { Text("Platform Type") + + Spacer(Modifier.height(8.dp)) + platformTypeRadioOptions.forEach { text -> - Row( - modifier = Modifier.selectable( - selected = (text == platformTypeRadioOptionsState.value), - onClick = { - platformTypeRadioOptionsState.value = text - } - ).padding(end = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colors.primary, - unselectedColor = MaterialTheme.colors.primaryVariant - ), - selected = (text == platformTypeRadioOptionsState.value), - onClick = { - platformTypeRadioOptionsState.value = text - } - ) - Text( - text = text, - style = MaterialTheme.typography.body1.merge(), - modifier = Modifier.padding(start = 8.dp) - ) - } + RadioButtonRow( + text = text, + selected = (text == platformTypeRadioOptionsState.value), + onClick = { platformTypeRadioOptionsState.value = text }, + modifier = Modifier.padding(end = 16.dp) + ) + + Spacer(Modifier.height(8.dp)) } val selectedSourceSets = remember { @@ -373,6 +358,8 @@ class ModuleMakerDialogWrapper( AnimatedVisibility( platformTypeSelection.value == MULTIPLATFORM ) { + Spacer(Modifier.height(8.dp)) + Column { Text(modifier = Modifier.padding(vertical = 8.dp), text = "Selected Source Sets: ${selectedSourceSets.joinToString(separator = ", ")}") @@ -392,8 +379,12 @@ class ModuleMakerDialogWrapper( } } + Spacer(Modifier.height(8.dp)) + Text("Test Source Sets") + Spacer(Modifier.height(8.dp)) + FlowRow(Modifier.padding(vertical = 8.dp)) { kotlinMultiplatformTestSourceSets.forEach { sourceSet -> LabelledCheckbox( @@ -413,20 +404,24 @@ class ModuleMakerDialogWrapper( } } + Spacer(Modifier.height(8.dp)) + val packageNameState = remember { packageName } - OutlinedTextField( - label = { Text("Package Name") }, - modifier = Modifier.fillMaxWidth(), + Text("Package Name") + TextField( + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Package Name" }, value = packageNameState.value, onValueChange = { packageNameState.value = it } ) + Spacer(Modifier.height(8.dp)) + val moduleNameState = remember { moduleName } - OutlinedTextField( - label = { Text("Module Name") }, - modifier = Modifier.fillMaxWidth(), + Text("Module Name") + TextField( + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Module Name" }, placeholder = { Text(DEFAULT_MODULE_NAME) }, @@ -485,7 +480,7 @@ class ModuleMakerDialogWrapper( // - we want to remove the root of the project to use as the file path in settings.gradle rootPathString = removeRootFromPath(selectedSrcValue.value), settingsGradleFile = settingsGradleFile, - modulePathAsString = moduleName.value, + modulePathAsString = moduleName.value.text, moduleType = moduleType, showErrorDialog = { analytics.track("module_creation_error", ModuleCreationErrorAnalytics(message = it)) @@ -506,7 +501,7 @@ class ModuleMakerDialogWrapper( enhancedModuleCreationStrategy = threeModuleCreation.value, useKtsBuildFile = useKtsExtension.value, gradleFileFollowModule = gradleFileNamedAfterModule.value, - packageName = packageName.value, + packageName = packageName.value.text, addReadme = addReadme.value, addGitIgnore = addGitIgnore.value, previewMode = previewMode, @@ -567,7 +562,3 @@ class ModuleMakerDialogWrapper( return path.split(File.separator).last() } } - -/* -kotlin multiplatform template in settings - create folders for each source set*/ diff --git a/src/main/kotlin/com/joetr/modulemaker/PreviewDialogWrapper.kt b/src/main/kotlin/com/joetr/modulemaker/PreviewDialogWrapper.kt index bb8f40c..67954b5 100644 --- a/src/main/kotlin/com/joetr/modulemaker/PreviewDialogWrapper.kt +++ b/src/main/kotlin/com/joetr/modulemaker/PreviewDialogWrapper.kt @@ -6,7 +6,6 @@ package com.joetr.modulemaker import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width -import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,11 +57,9 @@ class PreviewDialogWrapper(val filesToBeCreated: List, val root: String) : setBounds(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT) setContent { WidgetTheme { - Surface { - FileTreeJPanel( - modifier = Modifier.height(WINDOW_HEIGHT.dp).width(WINDOW_WIDTH.dp) - ) - } + FileTreeJPanel( + modifier = Modifier.height(WINDOW_HEIGHT.dp).width(WINDOW_WIDTH.dp) + ) } } } diff --git a/src/main/kotlin/com/joetr/modulemaker/SettingsDialogWrapper.kt b/src/main/kotlin/com/joetr/modulemaker/SettingsDialogWrapper.kt index 825ba19..de7e455 100644 --- a/src/main/kotlin/com/joetr/modulemaker/SettingsDialogWrapper.kt +++ b/src/main/kotlin/com/joetr/modulemaker/SettingsDialogWrapper.kt @@ -1,6 +1,8 @@ package com.joetr.modulemaker +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -9,13 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface -import androidx.compose.material.Tab -import androidx.compose.material.TabRow -import androidx.compose.material.Text -import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -23,7 +18,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue @@ -48,6 +42,17 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.jetbrains.annotations.Nullable +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.DefaultButton +import org.jetbrains.jewel.ui.component.SimpleTabContent +import org.jetbrains.jewel.ui.component.TabData +import org.jetbrains.jewel.ui.component.TabState +import org.jetbrains.jewel.ui.component.TabStrip +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextArea +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.theme.defaultTabStyle import java.awt.event.ActionEvent import java.io.FileWriter import java.nio.file.Files @@ -72,6 +77,7 @@ const val DEFAULT_API_MODULE_NAME = "api" const val DEFAULT_GLUE_MODULE_NAME = "glue" const val DEFAULT_IMPL_MODULE_NAME = "impl" +@OptIn(ExperimentalJewelApi::class) class SettingsDialogWrapper( private val project: Project, private val onSave: () -> Unit, @@ -140,24 +146,32 @@ class SettingsDialogWrapper( ) WidgetTheme { - Surface { - Column(modifier = Modifier.width(WINDOW_WIDTH.dp).height(WINDOW_HEIGHT.dp)) { - TabRow(selectedTabIndex = tabIndex, backgroundColor = Color.Transparent) { - tabs.forEachIndexed { index, title -> - Tab( - text = { Text(title) }, - selected = tabIndex == index, - onClick = { tabIndex = index } + Column(modifier = Modifier.width(WINDOW_WIDTH.dp).height(WINDOW_HEIGHT.dp)) { + val tabData = tabs.mapIndexed { index, title -> + TabData.Default( + selected = tabIndex == index, + content = { _ -> + SimpleTabContent( + label = title, + state = TabState.of(tabIndex == index) ) - } - } - when (tabIndex) { - 0 -> TemplateDefaultComponent() - 1 -> EnhancedTemplateDefaultComponent() - 2 -> MultiplatformDefaultComponent() - 3 -> GitIgnoreTemplateDefaultPanel() - 4 -> GeneralPanel() - } + }, + closable = false, + onClose = {}, + onClick = { tabIndex = index } + ) + } + TabStrip( + tabs = tabData, + style = JewelTheme.defaultTabStyle, + interactionSource = MutableInteractionSource() + ) + when (tabIndex) { + 0 -> TemplateDefaultComponent() + 1 -> EnhancedTemplateDefaultComponent() + 2 -> MultiplatformDefaultComponent() + 3 -> GitIgnoreTemplateDefaultPanel() + 4 -> GeneralPanel() } } } @@ -179,12 +193,14 @@ class SettingsDialogWrapper( ) val multiplatformModuleTemplateState = remember { multiplatformTemplateTextArea } - OutlinedTextField( - modifier = Modifier.fillMaxSize().padding(8.dp), + Spacer(Modifier.height(16.dp)) + + TextArea( value = multiplatformModuleTemplateState.value, onValueChange = { multiplatformModuleTemplateState.value = it - } + }, + modifier = Modifier.fillMaxSize().padding(8.dp) ) } } @@ -201,9 +217,11 @@ class SettingsDialogWrapper( """.trimIndent() ) + Spacer(Modifier.height(16.dp)) + val gitIgnoreTemplateState = remember { gitignoreTemplateTextArea } - OutlinedTextField( + TextArea( modifier = Modifier.fillMaxSize().padding(8.dp), value = gitIgnoreTemplateState.value, onValueChange = { @@ -220,28 +238,32 @@ class SettingsDialogWrapper( ) { var basePackageName by remember { packageNameTextField } + Text("Base Package Name:") TextField( value = basePackageName, onValueChange = { newValue -> basePackageName = newValue }, modifier = Modifier.padding(8.dp).fillMaxWidth(), - textStyle = TextStyle(fontFamily = FontFamily.SansSerif), - label = { Text("Base Package Name:") } + textStyle = TextStyle(fontFamily = FontFamily.SansSerif) ) + Spacer(Modifier.height(8.dp)) + var includeKeyword by remember { includeProjectKeywordTextField } + Text("Include keyword for settings.gradle(.kts):") TextField( value = includeKeyword, onValueChange = { newValue -> includeKeyword = newValue }, modifier = Modifier.padding(8.dp).fillMaxWidth(), - textStyle = TextStyle(fontFamily = FontFamily.SansSerif), - label = { Text("Include keyword for settings.gradle(.kts):") } + textStyle = TextStyle(fontFamily = FontFamily.SansSerif) ) + Spacer(Modifier.height(8.dp)) + val refreshAfterModuleCreationState = remember { refreshOnModuleAdd } LabelledCheckbox( label = "Refresh after creating modules", @@ -251,6 +273,8 @@ class SettingsDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val threeModuleState = remember { threeModuleCreation } LabelledCheckbox( label = "3 module creation checked by default", @@ -260,6 +284,8 @@ class SettingsDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val useKtsState = remember { ktsFileExtension } LabelledCheckbox( label = "Use .kts file extension checked by default", @@ -269,6 +295,8 @@ class SettingsDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val gradleFileNameState = remember { gradleFileNamedAfterModule } LabelledCheckbox( label = "Gradle file named after module by default", @@ -278,6 +306,8 @@ class SettingsDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val readmeState = remember { addReadme } LabelledCheckbox( label = "Add README.md by default", @@ -287,6 +317,8 @@ class SettingsDialogWrapper( } ) + Spacer(Modifier.height(8.dp)) + val gitIgnoreState = remember { addGitignore } LabelledCheckbox( label = "Add .gitignore by default", @@ -296,7 +328,9 @@ class SettingsDialogWrapper( } ) - Button( + Spacer(Modifier.height(16.dp)) + + DefaultButton( onClick = { importSettings() } @@ -304,7 +338,9 @@ class SettingsDialogWrapper( Text("Import Settings") } - Button( + Spacer(Modifier.height(16.dp)) + + DefaultButton( onClick = { exportSettings() } @@ -312,7 +348,9 @@ class SettingsDialogWrapper( Text("Export Settings") } - Button( + Spacer(Modifier.height(16.dp)) + + DefaultButton( onClick = { clearData() } @@ -348,9 +386,11 @@ class SettingsDialogWrapper( Text(settingExplanationText) + Spacer(Modifier.height(16.dp)) + val kotlinTemplateState = remember { kotlinTemplateTextArea } - OutlinedTextField( - label = { Text("Kotlin Template") }, + Text("Kotlin Template") + TextArea( modifier = Modifier.fillMaxWidth().padding(8.dp) .defaultMinSize(minHeight = (WINDOW_HEIGHT / 3).dp), value = kotlinTemplateState.value, @@ -360,8 +400,8 @@ class SettingsDialogWrapper( ) val androidTemplateState = remember { androidTemplateTextArea } - OutlinedTextField( - label = { Text("Android Template") }, + Text("Android Template") + TextArea( modifier = Modifier.fillMaxWidth().padding(8.dp) .defaultMinSize(minHeight = (WINDOW_HEIGHT / 3).dp), value = androidTemplateState.value, @@ -380,8 +420,8 @@ class SettingsDialogWrapper( modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) ) { val apiTemplateState = remember { apiTemplateTextArea } - OutlinedTextField( - label = { Text("Api Template") }, + Text("Api Template") + TextArea( modifier = Modifier.fillMaxWidth().padding(8.dp) .defaultMinSize(minHeight = (WINDOW_HEIGHT / 3).dp), value = apiTemplateState.value, @@ -391,8 +431,8 @@ class SettingsDialogWrapper( ) val glueTemplateState = remember { glueTemplateTextArea } - OutlinedTextField( - label = { Text("Glue Template") }, + Text("Glue Template") + TextArea( modifier = Modifier.fillMaxWidth().padding(8.dp) .defaultMinSize(minHeight = (WINDOW_HEIGHT / 3).dp), value = glueTemplateState.value, @@ -402,8 +442,8 @@ class SettingsDialogWrapper( ) val implTemplateState = remember { implTemplateTextArea } - OutlinedTextField( - label = { Text("Impl Template") }, + Text("Impl Template") + TextArea( modifier = Modifier.fillMaxWidth().padding(8.dp) .defaultMinSize(minHeight = (WINDOW_HEIGHT / 3).dp), value = implTemplateState.value, @@ -414,8 +454,8 @@ class SettingsDialogWrapper( val apiModuleNameState = remember { apiModuleNameTextArea } - OutlinedTextField( - label = { Text("Api Module Name") }, + Text("Api Module Name") + TextField( modifier = Modifier.fillMaxWidth().padding(8.dp), value = apiModuleNameState.value, onValueChange = { @@ -425,8 +465,8 @@ class SettingsDialogWrapper( val glueModuleNameState = remember { glueModuleNameTextArea } - OutlinedTextField( - label = { Text("Glue Module Name") }, + Text("Glue Module Name") + TextField( modifier = Modifier.fillMaxWidth().padding(8.dp), value = glueModuleNameState.value, onValueChange = { @@ -436,8 +476,8 @@ class SettingsDialogWrapper( val implModuleNameState = remember { implModuleNameTextArea } - OutlinedTextField( - label = { Text("Impl Module Name") }, + Text("Impl Module Name") + TextField( modifier = Modifier.fillMaxWidth().padding(8.dp), value = implModuleNameState.value, onValueChange = { diff --git a/src/main/kotlin/com/joetr/modulemaker/file/FileWriter.kt b/src/main/kotlin/com/joetr/modulemaker/file/FileWriter.kt index c7718b9..20b300b 100644 --- a/src/main/kotlin/com/joetr/modulemaker/file/FileWriter.kt +++ b/src/main/kotlin/com/joetr/modulemaker/file/FileWriter.kt @@ -390,13 +390,13 @@ class FileWriter( val twoParametersPattern = """\(".+", ".+"\)""".toRegex() - val lastNonEmptyLineInSettingsGradleFile = settingsFile.last { settingsFileLine -> + val lastNonEmptyLineInSettingsGradleFile = settingsFile.lastOrNull { settingsFileLine -> settingsFileLine.isNotEmpty() && includeKeywords.any { settingsFileLine.contains(it) } } val projectIncludeKeyword = includeKeywords.firstOrNull { includeKeyword -> - lastNonEmptyLineInSettingsGradleFile.contains(includeKeyword) + lastNonEmptyLineInSettingsGradleFile?.contains(includeKeyword) ?: false } if (projectIncludeKeyword == null) { diff --git a/src/main/kotlin/com/joetr/modulemaker/ui/LabelledCheckbox.kt b/src/main/kotlin/com/joetr/modulemaker/ui/LabelledCheckbox.kt index 442bdef..7d502b3 100644 --- a/src/main/kotlin/com/joetr/modulemaker/ui/LabelledCheckbox.kt +++ b/src/main/kotlin/com/joetr/modulemaker/ui/LabelledCheckbox.kt @@ -4,14 +4,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.ui.component.Checkbox +import org.jetbrains.jewel.ui.component.Text @Composable fun LabelledCheckbox( @@ -32,8 +30,7 @@ fun LabelledCheckbox( onCheckedChange = { onCheckedChange(it) }, - enabled = true, - colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colors.primary, uncheckedColor = MaterialTheme.colors.primaryVariant) + enabled = true ) Text(text = label) } diff --git a/src/main/kotlin/com/joetr/modulemaker/ui/file/FileTreeView.kt b/src/main/kotlin/com/joetr/modulemaker/ui/file/FileTreeView.kt index f3e38c4..6d0570f 100644 --- a/src/main/kotlin/com/joetr/modulemaker/ui/file/FileTreeView.kt +++ b/src/main/kotlin/com/joetr/modulemaker/ui/file/FileTreeView.kt @@ -19,39 +19,27 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Launch -import androidx.compose.material.icons.automirrored.filled.TextSnippet -import androidx.compose.material.icons.filled.BrokenImage -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Launch -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.TextSnippet import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.intellij.icons.AllIcons +import org.jetbrains.jewel.bridge.icon.fromPlatformIcon +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icon.IntelliJIconKey @Composable -fun FileTreeView(model: FileTree, height: Dp, onClick: (ExpandableFile) -> Unit, modifier: Modifier) = Surface( +fun FileTreeView(model: FileTree, height: Dp, onClick: (ExpandableFile) -> Unit, modifier: Modifier) = Box( modifier = modifier.height(height) ) { with(LocalDensity.current) { @@ -123,7 +111,7 @@ private fun FileTreeItemView( FileItemIcon(Modifier.align(Alignment.CenterVertically), model) Text( text = model.name, - color = if (active) LocalContentColor.current.copy(alpha = 0.60f) else LocalContentColor.current, + color = if (active) JewelTheme.contentColor.copy(alpha = 0.60f) else JewelTheme.contentColor, modifier = Modifier .align(Alignment.CenterVertically) .clipToBounds() @@ -141,40 +129,64 @@ private fun FileItemIcon(modifier: Modifier, model: FileTree.Item) = Box(modifie is FileTree.ItemType.Folder -> when { !type.canExpand -> Unit type.isExpanded -> Icon( - Icons.Default.KeyboardArrowDown, + key = IntelliJIconKey.fromPlatformIcon(AllIcons.General.ArrowDown), contentDescription = null, - tint = LocalContentColor.current + iconClass = AllIcons::class.java ) else -> Icon( - Icons.AutoMirrored.Filled.KeyboardArrowRight, + key = IntelliJIconKey.fromPlatformIcon(AllIcons.General.ArrowRight), contentDescription = null, - tint = LocalContentColor.current + iconClass = AllIcons::class.java ) } is FileTree.ItemType.File -> when (type.ext) { in sourceCodeFileExtensions -> Icon( - Icons.Default.Code, + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Java), contentDescription = null, - tint = Color(0xFF3E86A0) + iconClass = AllIcons::class.java + ) + "txt" -> Icon( + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Text), + contentDescription = null, + iconClass = AllIcons::class.java + ) + "md" -> Icon( + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Text), + contentDescription = null, + iconClass = AllIcons::class.java ) - "txt" -> Icon(Icons.Default.Description, contentDescription = null, tint = Color(0xFF87939A)) - "md" -> Icon(Icons.Default.Description, contentDescription = null, tint = Color(0xFF87939A)) "gitignore" -> Icon( - Icons.Default.BrokenImage, + key = IntelliJIconKey.fromPlatformIcon(AllIcons.Vcs.Ignore_file), contentDescription = null, - tint = Color(0xFF87939A) + iconClass = AllIcons::class.java + ) + "gradle" -> Icon( + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Config), + contentDescription = null, + iconClass = AllIcons::class.java + ) + "kts" -> Icon( + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Java), + contentDescription = null, + iconClass = AllIcons::class.java ) - "gradle" -> Icon(Icons.Default.Build, contentDescription = null, tint = Color(0xFF87939A)) - "kts" -> Icon(Icons.Default.Build, contentDescription = null, tint = Color(0xFF3E86A0)) "properties" -> Icon( - Icons.Default.Settings, + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Properties), + contentDescription = null, + iconClass = AllIcons::class.java + ) + "bat" -> Icon( + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Custom), + contentDescription = null, + iconClass = AllIcons::class.java + ) + else -> Icon( + key = IntelliJIconKey.fromPlatformIcon(AllIcons.FileTypes.Unknown), contentDescription = null, - tint = Color(0xFF62B543) + iconClass = AllIcons::class.java ) - "bat" -> Icon(Icons.AutoMirrored.Filled.Launch, contentDescription = null, tint = Color(0xFF87939A)) - else -> Icon(Icons.AutoMirrored.Filled.TextSnippet, contentDescription = null, tint = Color(0xFF87939A)) } } } diff --git a/src/main/kotlin/com/joetr/modulemaker/ui/theme/Shape.kt b/src/main/kotlin/com/joetr/modulemaker/ui/theme/Shape.kt deleted file mode 100644 index fe3352c..0000000 --- a/src/main/kotlin/com/joetr/modulemaker/ui/theme/Shape.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.joetr.modulemaker.ui.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val shapes = Shapes( - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(4.dp), - large = RoundedCornerShape(0.dp) -) diff --git a/src/main/kotlin/com/joetr/modulemaker/ui/theme/Type.kt b/src/main/kotlin/com/joetr/modulemaker/ui/theme/Type.kt deleted file mode 100644 index 285dc78..0000000 --- a/src/main/kotlin/com/joetr/modulemaker/ui/theme/Type.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.joetr.modulemaker.ui.theme - -import androidx.compose.material.Typography -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -val typography = Typography( - body1 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp - ), - body2 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 14.sp - ), - button = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.W500, - fontSize = 14.sp - ), - caption = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 12.sp - ), - subtitle1 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - color = Color.Gray - ), - subtitle2 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - color = Color.Gray - ) -) diff --git a/src/main/kotlin/com/joetr/modulemaker/ui/theme/WidgetTheme.kt b/src/main/kotlin/com/joetr/modulemaker/ui/theme/WidgetTheme.kt index 08355ef..dcb88ca 100644 --- a/src/main/kotlin/com/joetr/modulemaker/ui/theme/WidgetTheme.kt +++ b/src/main/kotlin/com/joetr/modulemaker/ui/theme/WidgetTheme.kt @@ -1,52 +1,14 @@ package com.joetr.modulemaker.ui.theme -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import com.joetr.modulemaker.ui.theme.intellij.SwingColor -import kotlinx.serialization.json.JsonNull.content - -private val DarkGreenColorPalette = darkColors( - primary = blue200, - primaryVariant = blue700, - secondary = teal200, - onPrimary = Color.Black, - onSecondary = Color.White, - error = Color.Red -) - -private val LightGreenColorPalette = lightColors( - primary = blue500, - primaryVariant = blue700, - secondary = teal200, - onPrimary = Color.White, - onSurface = Color.Black -) +import org.jetbrains.jewel.bridge.theme.SwingBridgeTheme @Composable fun WidgetTheme( darkTheme: Boolean = false, - content: @Composable() - () -> Unit + content: @Composable () -> Unit ) { - val colors = if (darkTheme) DarkGreenColorPalette else LightGreenColorPalette - val swingColor = SwingColor() - - MaterialTheme( - colors = colors.copy( - background = swingColor.background, - onBackground = swingColor.onBackground, - surface = swingColor.background, - onSurface = swingColor.onBackground - ), - typography = typography, - shapes = shapes - ) { - Surface { - content() - } + SwingBridgeTheme { + content() } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index e444eec..cdcf13b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -6,6 +6,7 @@ https://joetr.com com.intellij.modules.lang + com.intellij.modules.compose Enables the creation of modules with sensible defaults. diff --git a/src/uiTest/kotlin/com/joetr/modulemaker/ModuleMakerUiTest.kt b/src/uiTest/kotlin/com/joetr/modulemaker/ModuleMakerUiTest.kt new file mode 100644 index 0000000..5ad00a5 --- /dev/null +++ b/src/uiTest/kotlin/com/joetr/modulemaker/ModuleMakerUiTest.kt @@ -0,0 +1,294 @@ +package com.joetr.modulemaker + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ComponentFixture +import com.intellij.remoterobot.fixtures.JButtonFixture +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import com.intellij.remoterobot.utils.keyboard +import com.intellij.remoterobot.utils.waitFor +import org.junit.Test +import java.awt.event.KeyEvent +import java.io.File +import java.time.Duration + +/** + * UI test that verifies the Module Maker plugin opens and can create a module. + * + * Prerequisites: the IDE must be running with the robot server plugin on port 8082. + * Start it with: ./run-ui-tests.sh (or two terminals: runIdeForUiTests then uiTest) + * + * Compose interaction: + * Remote-robot's keyboard/mouse APIs don't reach the Skia canvas inside + * ComposePanel. We dispatch Swing MouseEvent/KeyEvent directly to the + * compose panel component via callJs + component.dispatchEvent(). This avoids + * java.awt.Robot (which requires macOS accessibility permissions) and works because + * ComposePanel forwards dispatched Swing events to the compose layer. + */ +class ModuleMakerUiTest { + + private val robotPort = System.getProperty("robot-server.port", "8082") + private val remoteRobot = RemoteRobot("http://127.0.0.1:$robotPort") + + @Test + fun `opens via Find Action and creates repository module`() { + with(remoteRobot) { + step("Wait for IDE frame to load") { + waitFor(duration = Duration.ofMinutes(3), interval = Duration.ofSeconds(2)) { + dismissBlockingDialogs() + findAll( + byXpath("//div[@class='IdeFrameImpl']") + ).isNotEmpty() + } + } + + step("Open Module Maker via Find Action") { + find( + byXpath("//div[@class='IdeFrameImpl']"), + Duration.ofSeconds(10) + ).click() + + val isMac = System.getProperty("os.name").contains("Mac", ignoreCase = true) + keyboard { + if (isMac) { + hotKey(KeyEvent.VK_META, KeyEvent.VK_SHIFT, KeyEvent.VK_A) + } else { + hotKey(KeyEvent.VK_CONTROL, KeyEvent.VK_SHIFT, KeyEvent.VK_A) + } + } + Thread.sleep(1_000) + keyboard { enterText("Module Maker") } + Thread.sleep(500) + keyboard { hotKey(KeyEvent.VK_ENTER) } + } + + step("Verify Module Maker dialog opened") { + waitFor(duration = Duration.ofSeconds(15)) { + findAll( + byXpath("//div[@title='Module Maker']") + ).isNotEmpty() + } + } + + val dialog = find( + byXpath("//div[@title='Module Maker']"), + Duration.ofSeconds(10) + ) + + val isMac = System.getProperty("os.name").contains("Mac", ignoreCase = true) + + // Strategy: Use the Create button as a stable anchor point, then + // Shift+Tab backwards into the compose fields. The layout order + // (bottom-up) is: Create button → Module Name → Package Name. + // This avoids fragile forward-tabbing from the top of the dialog. + + step("Focus Module Name via Shift+Tab from Create button") { + // Focus (don't click!) the Create button as a stable tab-order anchor + dialog.find( + byXpath("//div[@text='Create']"), + Duration.ofSeconds(10) + ).callJs("component.requestFocusInWindow(); true") + Thread.sleep(500) + + // Shift+Tab from Create → lands on Module Name field + for (i in 1..SHIFT_TABS_CREATE_TO_MODULE_NAME) { + dispatchKey(dialog, KeyEvent.VK_TAB, shift = true) + Thread.sleep(150) + } + Thread.sleep(300) + } + + step("Type module name") { + dispatchText(dialog, ":repository") + Thread.sleep(300) + } + + step("Shift+Tab to Package Name and type") { + dispatchKey(dialog, KeyEvent.VK_TAB, shift = true) + Thread.sleep(300) + + // Select all existing text and replace + if (isMac) { + dispatchKey(dialog, KeyEvent.VK_A, meta = true) + } else { + dispatchKey(dialog, KeyEvent.VK_A, ctrl = true) + } + Thread.sleep(200) + dispatchText(dialog, "com.example") + Thread.sleep(300) + } + + step("Click Create button") { + dialog.find( + byXpath("//div[@text='Create']"), + Duration.ofSeconds(10) + ).click() + } + + step("Dismiss any post-creation dialog") { + Thread.sleep(3_000) + // Try to dismiss success or error dialog + findAll( + byXpath("//div[@text='Okay']") + ).firstOrNull()?.click() + findAll( + byXpath("//div[@class='JButton' and @text='OK']") + ).firstOrNull()?.click() + Thread.sleep(500) + } + + step("Verify module was created on disk") { + val testProjectDir = File(System.getProperty("user.dir")) + .resolve("src/uiTest/testProject") + val repositoryDir = testProjectDir.resolve("repository") + assert(repositoryDir.exists() && repositoryDir.isDirectory) { + "Expected repository directory at ${repositoryDir.absolutePath}" + } + val buildFile = repositoryDir.resolve("build.gradle.kts") + assert(buildFile.exists()) { + "Expected build.gradle.kts at ${buildFile.absolutePath}" + } + val srcDir = repositoryDir.resolve("src") + assert(srcDir.exists() && srcDir.isDirectory) { + "Expected src directory at ${srcDir.absolutePath}" + } + println("Module creation verified: ${repositoryDir.absolutePath}") + } + } + } + + // ── Dispatch helpers ─────────────────────────────────────────────────────── + // Events must be dispatched to the Skia layer component (child of ComposePanel), + // not the ComposePanel itself. The hierarchy is: + // ComposePanel → [InvisibleComponent, SwingSkiaLayerComponent$contentComponent$1] + // The Skia layer (index 1) is the actual input-handling surface. + + private fun findComposePanel(dialog: CommonContainerFixture): ComponentFixture = + dialog.find(byXpath("//div[@class='ComposePanel']"), Duration.ofSeconds(10)) + + /** Dispatch a mouse click at (x, y) relative to the compose panel. */ + private fun dispatchClick(dialog: CommonContainerFixture, x: Int, y: Int) { + findComposePanel(dialog).callJs( + """ + var target = component.getComponent(1); + target.requestFocusInWindow(); + var now = java.lang.System.currentTimeMillis(); + target.dispatchEvent(new java.awt.event.MouseEvent( + target, java.awt.event.MouseEvent.MOUSE_PRESSED, now, 0, + $x, $y, 1, false, java.awt.event.MouseEvent.BUTTON1)); + target.dispatchEvent(new java.awt.event.MouseEvent( + target, java.awt.event.MouseEvent.MOUSE_RELEASED, now + 50, 0, + $x, $y, 1, false, java.awt.event.MouseEvent.BUTTON1)); + target.dispatchEvent(new java.awt.event.MouseEvent( + target, java.awt.event.MouseEvent.MOUSE_CLICKED, now + 50, 0, + $x, $y, 1, false, java.awt.event.MouseEvent.BUTTON1)); + true + """ + ) + } + + /** Dispatch a key press+release to the Skia layer. */ + private fun dispatchKey( + dialog: CommonContainerFixture, + keyCode: Int, + ctrl: Boolean = false, + meta: Boolean = false, + shift: Boolean = false + ) { + val modifiers = mutableListOf() + if (ctrl) modifiers.add("java.awt.event.InputEvent.CTRL_DOWN_MASK") + if (meta) modifiers.add("java.awt.event.InputEvent.META_DOWN_MASK") + if (shift) modifiers.add("java.awt.event.InputEvent.SHIFT_DOWN_MASK") + val modExpr = if (modifiers.isEmpty()) "0" else modifiers.joinToString(" | ") + + findComposePanel(dialog).callJs( + """ + var target = component.getComponent(1); + var now = java.lang.System.currentTimeMillis(); + target.dispatchEvent(new java.awt.event.KeyEvent( + target, java.awt.event.KeyEvent.KEY_PRESSED, now, + $modExpr, $keyCode, java.awt.event.KeyEvent.CHAR_UNDEFINED)); + target.dispatchEvent(new java.awt.event.KeyEvent( + target, java.awt.event.KeyEvent.KEY_RELEASED, now + 30, + $modExpr, $keyCode, java.awt.event.KeyEvent.CHAR_UNDEFINED)); + true + """ + ) + } + + /** Dispatch KEY_TYPED events for each character in [text]. */ + private fun dispatchText(dialog: CommonContainerFixture, text: String) { + for (ch in text) { + dispatchChar(dialog, ch) + Thread.sleep(50) + } + } + + private fun dispatchChar(dialog: CommonContainerFixture, ch: Char) { + // For typed characters, we need KEY_TYPED with the char value. + // Some characters also need KEY_PRESSED/KEY_RELEASED for the compose layer. + val (keyCode, shift) = when { + ch in 'a'..'z' -> (KeyEvent.VK_A + (ch - 'a')) to false + ch in 'A'..'Z' -> (KeyEvent.VK_A + (ch - 'A')) to true + ch in '0'..'9' -> (KeyEvent.VK_0 + (ch - '0')) to false + ch == '.' -> KeyEvent.VK_PERIOD to false + ch == ':' -> KeyEvent.VK_SEMICOLON to true + ch == '-' -> KeyEvent.VK_MINUS to false + ch == '_' -> KeyEvent.VK_MINUS to true + else -> throw IllegalArgumentException("Unsupported character: '$ch'") + } + + val modExpr = if (shift) "java.awt.event.InputEvent.SHIFT_DOWN_MASK" else "0" + + findComposePanel(dialog).callJs( + """ + var target = component.getComponent(1); + var now = java.lang.System.currentTimeMillis(); + target.dispatchEvent(new java.awt.event.KeyEvent( + target, java.awt.event.KeyEvent.KEY_PRESSED, now, + $modExpr, $keyCode, '$ch')); + target.dispatchEvent(new java.awt.event.KeyEvent( + target, java.awt.event.KeyEvent.KEY_TYPED, now + 10, + 0, java.awt.event.KeyEvent.VK_UNDEFINED, '$ch')); + target.dispatchEvent(new java.awt.event.KeyEvent( + target, java.awt.event.KeyEvent.KEY_RELEASED, now + 30, + $modExpr, $keyCode, '$ch')); + true + """ + ) + } + + // ── Utilities ────────────────────────────────────────────────────────────── + + private fun RemoteRobot.dismissBlockingDialogs() { + // Trust project dialogs + listOf("Trust Project", "Trust and Open Project", "Trust Projects").forEach { label -> + findAll(byXpath("//div[@text='$label']")).firstOrNull()?.click() + } + // Data sharing / telemetry + findAll(byXpath("//div[@text=\"Don't Send\"]")).firstOrNull()?.click() + // Generic OK/Close buttons on any dialog + findAll(byXpath("//div[@class='JButton' and @text='OK']")).firstOrNull()?.click() + findAll(byXpath("//div[@class='JButton' and @text='Close']")).firstOrNull()?.click() + // Tip of the Day + findAll(byXpath("//div[@class='JButton' and @text='Got It']")).firstOrNull()?.click() + // What's New / changelog + findAll(byXpath("//div[@text='Got It']")).firstOrNull()?.click() + // Import Settings dialog + findAll(byXpath("//div[@text='Do not import settings']")).firstOrNull()?.click() + findAll(byXpath("//div[@text='Skip Import']")).firstOrNull()?.click() + // License agreement + findAll(byXpath("//div[@text='Accept']")).firstOrNull()?.click() + // Any "Continue" or "Skip" buttons + findAll(byXpath("//div[@text='Continue']")).firstOrNull()?.click() + findAll(byXpath("//div[@text='Skip Remaining and Set Defaults']")).firstOrNull()?.click() + } + + private companion object { + // Number of Shift+Tab presses from the Create button to the Module Name field. + // Layout bottom-up: Create → Module Name (1) → Package Name (2). + // Adjust if buttons or fields are added between Create and Module Name. + const val SHIFT_TABS_CREATE_TO_MODULE_NAME = 1 + } +} diff --git a/src/uiTest/testProject/.gitignore b/src/uiTest/testProject/.gitignore new file mode 100644 index 0000000..a9370f0 --- /dev/null +++ b/src/uiTest/testProject/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.idea/ +build/ +local.properties +gradlew +gradlew.bat +gradle/ diff --git a/src/uiTest/testProject/settings.gradle.kts b/src/uiTest/testProject/settings.gradle.kts new file mode 100644 index 0000000..f51f943 --- /dev/null +++ b/src/uiTest/testProject/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "test-project" +include(":app") \ No newline at end of file