diff --git a/README.md b/README.md index 3d083ab..b8f29d7 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,5 @@ Building release distributables will fail if the required JDK tools are not avai # Run release build type from build installation ./gradlew :gradle-client:runReleaseDistributable ``` + +To add more actions start from [GetModelAction.kt](./gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/actions/GetModelAction.kt). diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/gradle/GradleConnectionParameters.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/gradle/GradleConnectionParameters.kt index 455835e..a3a0d21 100644 --- a/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/gradle/GradleConnectionParameters.kt +++ b/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/gradle/GradleConnectionParameters.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable @Serializable data class GradleConnectionParameters( val rootDir: String, - val javaHome: String, + val javaHomeDir: String?, + val gradleUserHomeDir: String?, val distribution: GradleDistribution, ) diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/util/Identity.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/util/Identity.kt index 6f317ac..0f92dac 100644 --- a/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/util/Identity.kt +++ b/gradle-client/src/jvmMain/kotlin/org/gradle/client/core/util/Identity.kt @@ -1,6 +1,6 @@ package org.gradle.client.core.util -import java.util.UUID +import java.util.* fun generateIdentity(): String = UUID.randomUUID().toString() diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/build/BuildContent.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/build/BuildContent.kt index eca14b9..346dd76 100644 --- a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/build/BuildContent.kt +++ b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/build/BuildContent.kt @@ -2,31 +2,38 @@ package org.gradle.client.ui.build import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState +import kotlinx.coroutines.launch import org.gradle.client.core.gradle.GradleConnectionParameters +import org.gradle.client.core.gradle.GradleDistribution import org.gradle.client.ui.composables.BackIcon +import org.gradle.client.ui.composables.DirChooserDialog import org.gradle.client.ui.composables.Loading -import org.gradle.client.ui.composables.PathChooserDialog import org.gradle.client.ui.composables.PlainTextTooltip -import org.gradle.client.core.gradle.GradleDistribution import org.gradle.client.ui.theme.plusPaneSpacing +import java.io.File @Composable fun BuildContent(component: BuildComponent) { + val snackbarHostState = remember { SnackbarHostState() } Scaffold( - topBar = { TopBar(component) } + topBar = { TopBar(component) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { scaffoldPadding -> Surface(modifier = Modifier.padding(scaffoldPadding.plusPaneSpacing())) { val model by component.model.subscribeAsState() when (val current = model) { BuildModel.Loading -> Loading() - is BuildModel.Loaded -> BuildMainContent(component, current) + is BuildModel.Loaded -> BuildMainContent(component, current, snackbarHostState) } } } @@ -42,23 +49,46 @@ enum class GradleDistSource( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun BuildMainContent(component: BuildComponent, model: BuildModel.Loaded) { +private fun BuildMainContent( + component: BuildComponent, + model: BuildModel.Loaded, + snackbarHostState: SnackbarHostState, +) { - var javaHome by remember { mutableStateOf(System.getenv("JAVA_HOME") ?: "") } - var gradleDistSource by remember { mutableStateOf(GradleDistSource.DEFAULT) } - var gradleDistVersion by remember { mutableStateOf("") } - var gradleDistLocalDir by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() - val isJavaHomeValid by derivedStateOf { javaHome.isNotBlank() } - val isGradleDistVersionValid by derivedStateOf { gradleDistVersion.isNotBlank() } - val isGradleDistLocalDirValid by derivedStateOf { gradleDistLocalDir.isNotBlank() } - val isCanConnect by derivedStateOf { - isJavaHomeValid && when (gradleDistSource) { - GradleDistSource.DEFAULT -> true - GradleDistSource.VERSION -> isGradleDistVersionValid - GradleDistSource.LOCAL -> isGradleDistLocalDirValid + var javaHome by rememberSaveable { mutableStateOf("") } + var gradleUserHome by rememberSaveable { mutableStateOf("") } + var gradleDistSource by rememberSaveable { mutableStateOf(GradleDistSource.DEFAULT) } + var gradleDistVersion by rememberSaveable { mutableStateOf("") } + var gradleDistLocalDir by rememberSaveable { mutableStateOf("") } + + val isJavaHomeValid by derivedStateOf { + javaHome.isBlank() || File(javaHome).let { + it.isDirectory && it.resolve("bin").listFiles { file -> + file.nameWithoutExtension == "java" + }?.isNotEmpty() ?: false } } + val isGradleUserHomeValid by derivedStateOf { + gradleUserHome.isBlank() || File(gradleUserHome).let { !it.exists() || it.isDirectory } + } + val isGradleDistVersionValid by derivedStateOf { + gradleDistVersion.isNotBlank() + } + val isGradleDistLocalDirValid by derivedStateOf { + gradleDistLocalDir.isNotBlank() && File(gradleDistLocalDir).resolve("bin").listFiles { file -> + file.nameWithoutExtension == "gradle" + }?.isNotEmpty() ?: false + } + val isCanConnect by derivedStateOf { + isJavaHomeValid && isGradleUserHomeValid && + when (gradleDistSource) { + GradleDistSource.DEFAULT -> true + GradleDistSource.VERSION -> isGradleDistVersionValid + GradleDistSource.LOCAL -> isGradleDistLocalDirValid + } + } Column( modifier = Modifier.fillMaxWidth(), @@ -75,28 +105,77 @@ private fun BuildMainContent(component: BuildComponent, model: BuildModel.Loaded OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = javaHome, + readOnly = false, onValueChange = { javaHome = it }, label = { Text("Java Home") }, + placeholder = { Text(System.getenv("JAVA_HOME") ?: "", color = Color.Gray) }, isError = !isJavaHomeValid, trailingIcon = { - val helpText = "Select a java executable" - var isPathChooserOpen by remember { mutableStateOf(false) } - if (isPathChooserOpen) { - PathChooserDialog( + val helpText = "Select a Java home" + var isDirChooserOpen by remember { mutableStateOf(false) } + if (isDirChooserOpen) { + DirChooserDialog( helpText = helpText, - selectableFilter = { path -> path.isFile && path.nameWithoutExtension == "java" }, - choiceMapper = { path -> path.parentFile.parentFile }, - onPathChosen = { path -> - isPathChooserOpen = false - if (path != null) { - javaHome = path.absolutePath + showHiddenFiles = true, + onDirChosen = { dir -> + isDirChooserOpen = false + if (dir == null) { + scope.launch { snackbarHostState.showSnackbar("No Java home selected") } + } else { + javaHome = dir.absolutePath } } ) } - PlainTextTooltip(helpText) { - IconButton(onClick = { isPathChooserOpen = true }) { - Icon(Icons.Default.Folder, helpText) + Row { + IconButton( + enabled = javaHome.isNotBlank(), + onClick = { javaHome = "" }, + content = { Icon(Icons.Default.Clear, "Clear") } + ) + PlainTextTooltip(helpText) { + IconButton(onClick = { isDirChooserOpen = true }) { + Icon(Icons.Default.Folder, helpText) + } + } + } + } + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = gradleUserHome, + readOnly = false, + onValueChange = { gradleUserHome = it }, + label = { Text("Gradle User Home") }, + placeholder = { Text(System.getProperty("user.home") + "/.gradle", color = Color.Gray) }, + isError = !isGradleUserHomeValid, + trailingIcon = { + val helpText = "Select a Gradle user home" + var isDirChooserOpen by remember { mutableStateOf(false) } + if (isDirChooserOpen) { + DirChooserDialog( + helpText = helpText, + showHiddenFiles = true, + onDirChosen = { dir -> + isDirChooserOpen = false + if (dir == null) { + scope.launch { snackbarHostState.showSnackbar("No Gradle user home selected") } + } else { + gradleUserHome = dir.absolutePath + } + } + ) + } + Row { + IconButton( + enabled = gradleUserHome.isNotBlank(), + onClick = { gradleUserHome = "" }, + content = { Icon(Icons.Default.Clear, "Clear") } + ) + PlainTextTooltip(helpText) { + IconButton(onClick = { isDirChooserOpen = true }) { + Icon(Icons.Default.Folder, helpText) + } } } } @@ -178,24 +257,31 @@ private fun BuildMainContent(component: BuildComponent, model: BuildModel.Loaded label = { Text("Local installation path") }, isError = !isGradleDistLocalDirValid, trailingIcon = { - val helpText = "Select a gradle executable" - var isPathChooserOpen by remember { mutableStateOf(false) } - if (isPathChooserOpen) { - PathChooserDialog( + val helpText = "Select a Gradle installation" + var isDirChooserOpen by remember { mutableStateOf(false) } + if (isDirChooserOpen) { + DirChooserDialog( helpText = helpText, - selectableFilter = { path -> path.isFile && path.nameWithoutExtension == "gradle" }, - choiceMapper = { path -> path.parentFile.parentFile }, - onPathChosen = { path -> - isPathChooserOpen = false - if (path != null) { - gradleDistLocalDir = path.absolutePath + onDirChosen = { dir -> + isDirChooserOpen = false + if (dir == null) { + scope.launch { snackbarHostState.showSnackbar("No Gradle installation selected") } + } else { + gradleDistLocalDir = dir.absolutePath } } ) } - PlainTextTooltip(helpText) { - IconButton(onClick = { isPathChooserOpen = true }) { - Icon(Icons.Default.Folder, helpText) + Row { + IconButton( + enabled = gradleDistLocalDir.isNotBlank(), + onClick = { gradleDistLocalDir = "" }, + content = { Icon(Icons.Default.Clear, "Clear") } + ) + PlainTextTooltip(helpText) { + IconButton(onClick = { isDirChooserOpen = true }) { + Icon(Icons.Default.Folder, helpText) + } } } } @@ -209,9 +295,10 @@ private fun BuildMainContent(component: BuildComponent, model: BuildModel.Loaded onClick = { component.onConnectClicked( GradleConnectionParameters( - model.build.rootDir.absolutePath, - javaHome, - when (gradleDistSource) { + rootDir = model.build.rootDir.absolutePath, + javaHomeDir = javaHome.takeIf { it.isNotBlank() }, + gradleUserHomeDir = gradleUserHome.takeIf { it.isNotBlank() }, + distribution = when (gradleDistSource) { GradleDistSource.DEFAULT -> GradleDistribution.Default GradleDistSource.VERSION -> GradleDistribution.Version(gradleDistVersion) GradleDistSource.LOCAL -> GradleDistribution.Local(gradleDistLocalDir) diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/buildlist/BuildListContent.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/buildlist/BuildListContent.kt index 6fbf319..55eff0b 100644 --- a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/buildlist/BuildListContent.kt +++ b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/buildlist/BuildListContent.kt @@ -15,18 +15,21 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState +import kotlinx.coroutines.launch import org.gradle.client.core.Constants.APPLICATION_DISPLAY_NAME import org.gradle.client.core.database.Build +import org.gradle.client.ui.composables.DirChooserDialog import org.gradle.client.ui.composables.Loading -import org.gradle.client.ui.composables.PathChooserDialog import org.gradle.client.ui.composables.PlainTextTooltip import org.gradle.client.ui.theme.plusPaneSpacing @Composable fun BuildListContent(component: BuildListComponent) { + val snackbarHostState = remember { SnackbarHostState() } Scaffold( topBar = { TopBar() }, - floatingActionButton = { AddBuildButton(component) }, + floatingActionButton = { AddBuildButton(component, snackbarHostState) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { scaffoldPadding -> Surface(Modifier.padding(scaffoldPadding.plusPaneSpacing())) { val model by component.model.subscribeAsState() @@ -89,21 +92,25 @@ private fun TopBar() { } @Composable -private fun AddBuildButton(component: BuildListComponent) { - var isPathChooserOpen by remember { mutableStateOf(false) } - if (isPathChooserOpen) { - PathChooserDialog( +private fun AddBuildButton(component: BuildListComponent, snackbarHostState: SnackbarHostState) { + val scope = rememberCoroutineScope() + var isDirChooserOpen by remember { mutableStateOf(false) } + if (isDirChooserOpen) { + DirChooserDialog( helpText = addBuildHelpText, - selectableFilter = { path -> - path.isFile && path.name.startsWith("settings.gradle") - }, - choiceMapper = { path -> - path.parentFile - }, - onPathChosen = { rootDir -> - isPathChooserOpen = false - if (rootDir != null) { - component.onNewBuildRootDirChosen(rootDir) + onDirChosen = { rootDir -> + isDirChooserOpen = false + if (rootDir == null) { + scope.launch { snackbarHostState.showSnackbar("No build selected") } + } else { + val valid = rootDir.listFiles { file -> + file.name.startsWith("settings.gradle") + }?.isNotEmpty() ?: false + if (!valid) { + scope.launch { snackbarHostState.showSnackbar("Directory is not a Gradle build!") } + } else { + component.onNewBuildRootDirChosen(rootDir) + } } } ) @@ -112,9 +119,9 @@ private fun AddBuildButton(component: BuildListComponent) { ExtendedFloatingActionButton( icon = { Icon(Icons.Default.Add, "") }, text = { Text(text = "Add build", Modifier.testTag("add_build")) }, - onClick = { isPathChooserOpen = true }, + onClick = { isDirChooserOpen = true }, ) } } -private const val addBuildHelpText = "Choose a Gradle settings script" +private const val addBuildHelpText = "Choose a Gradle build directory" diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/composables/DirChooserDialog.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/composables/DirChooserDialog.kt new file mode 100644 index 0000000..ed52e9d --- /dev/null +++ b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/composables/DirChooserDialog.kt @@ -0,0 +1,27 @@ +package org.gradle.client.ui.composables + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import java.io.File +import javax.swing.JFileChooser + +@Composable +fun DirChooserDialog( + helpText: String, + showHiddenFiles: Boolean = false, + onDirChosen: (dir: File?) -> Unit, +) { + LaunchedEffect(Unit) { + val chooser = JFileChooser() + chooser.dialogTitle = helpText + chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + chooser.isAcceptAllFileFilterUsed = false + chooser.isMultiSelectionEnabled = false + chooser.isFileHidingEnabled = !showHiddenFiles + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + onDirChosen(chooser.selectedFile) + } else { + onDirChosen(null) + } + } +} diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/composables/PathChooserDialog.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/composables/PathChooserDialog.kt deleted file mode 100644 index db1e46e..0000000 --- a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/composables/PathChooserDialog.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.gradle.client.ui.composables - -import androidx.compose.runtime.Composable -import androidx.compose.ui.window.AwtWindow -import java.awt.FileDialog -import java.awt.Frame -import java.io.File - -@Composable -fun PathChooserDialog( - helpText: String = "Select a path", - selectableFilter: (path: File) -> Boolean = { true }, - choiceMapper: (path: File) -> File = { it }, - onPathChosen: (path: File?) -> Unit, -) = AwtWindow( - create = { - object : FileDialog(null as Frame?, helpText, LOAD) { - - init { - setFilenameFilter { dir, name -> - selectableFilter(dir.resolve(name)) - } - } - - override fun setVisible(value: Boolean) { - super.setVisible(value) - if (value) { - if (directory == null || name == null) onPathChosen(null) - else onPathChosen(choiceMapper(File(directory).resolve(name))) - } - } - } - }, - dispose = FileDialog::dispose -) diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedComponent.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedComponent.kt index 35a464c..c52827a 100644 --- a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedComponent.kt +++ b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedComponent.kt @@ -72,9 +72,15 @@ class ConnectedComponent( val cancel = GradleConnector.newCancellationTokenSource() val connector = GradleConnector.newConnector() .forProjectDirectory(File(parameters.rootDir)) + .let { c -> + when (parameters.gradleUserHomeDir) { + null -> c + else -> c.useGradleUserHomeDir(File(parameters.gradleUserHomeDir)) + } + } .let { c -> when (parameters.distribution) { - GradleDistribution.Default -> c + GradleDistribution.Default -> c.useBuildDistribution() is GradleDistribution.Local -> c.useInstallation(File(parameters.distribution.installDir)) is GradleDistribution.Version -> c.useGradleVersion(parameters.distribution.version) } @@ -107,6 +113,12 @@ class ConnectedComponent( logger.atDebug().log { "Get ${modelType.simpleName} model!" } try { val result = connection.model(modelType.java) + .let { b -> + when (parameters.javaHomeDir) { + null -> b + else -> b.addArguments("-Dorg.gradle.java.home=${parameters.javaHomeDir}") + } + } .addProgressListener( newEventListener(), OperationType.entries.toSet() - OperationType.GENERIC diff --git a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedContent.kt b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedContent.kt index b1038b9..6d9d91b 100644 --- a/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedContent.kt +++ b/gradle-client/src/jvmMain/kotlin/org/gradle/client/ui/connected/ConnectedContent.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -123,7 +122,6 @@ private fun ConnectedMainContent(component: ConnectedComponent, model: Connectio Icon(Icons.Default.ArrowDownward, "Bottom") } } - } }, sheetContent = { diff --git a/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/GradleClientUiTest.kt b/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/GradleClientUiTest.kt index cfee9a3..822ab00 100644 --- a/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/GradleClientUiTest.kt +++ b/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/GradleClientUiTest.kt @@ -128,13 +128,9 @@ class GradleClientUiTest { rule.onNodeWithText( connected.component.modelActions.single { it.modelType == GradleBuild::class }.displayName ).performClick() - assertThat( - (connected.component.model.value as ConnectionModel.Connected).outcome, - instanceOf(Outcome.Building::class.java) - ) - advanceUntilIdle() // Displays model + advanceUntilIdle() rule.onNodeWithText("Root Project Name: some-root").assertIsDisplayed() } finally { diff --git a/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/fixtures/TestAppDirs.kt b/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/fixtures/TestAppDirs.kt index d56c253..cc7cb9d 100644 --- a/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/fixtures/TestAppDirs.kt +++ b/gradle-client/src/jvmTest/kotlin/org/gradle/client/ui/fixtures/TestAppDirs.kt @@ -15,4 +15,4 @@ class TestAppDirs( private fun sub(name: String): File = rootDir.resolve(name).also { it.mkdirs() } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 773c009..fccc9c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,11 +11,8 @@ ktor = "2.3.9" [libraries] kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "kotlinx-coroutines" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing" } kotlinx-serialization-bom = { module = "org.jetbrains.kotlinx:kotlinx-serialization-bom", version.ref = "kotlinx-serialization" } -kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json" } sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } sqldelight-extensions-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } @@ -38,4 +35,3 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } -ktor = { id = "io.ktor.plugin", version.ref = "ktor" }