Browse Source

Implement first version of non-interactive preview for desktop (#803)

Implement first version of non-interactive preview for desktop
pull/830/head
Alexey Tsvetkov 4 years ago committed by GitHub
parent
commit
2be46f961e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      gradle-plugins/build.gradle.kts
  2. 43
      gradle-plugins/buildSrc/src/main/kotlin/gradleUtils.kt
  3. 14
      gradle-plugins/compose-preview-runtime/build.gradle.kts
  4. 40
      gradle-plugins/compose-preview-runtime/src/main/kotlin/org/jetbrains/compose/desktop/preview/runtime/ComposePreviewRunner.kt
  5. 17
      gradle-plugins/compose/build.gradle.kts
  6. 1
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt
  7. 8
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt
  8. 13
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/internal/configurePreview.kt
  9. 82
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractConfigureDesktopPreviewTask.kt
  10. 39
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractRunComposePreviewTask.kt
  11. 32
      gradle-plugins/preview-rpc/build.gradle.kts
  12. 38
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/Command.kt
  13. 11
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/ExitCodes.kt
  14. 36
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewLogger.kt
  15. 253
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewManager.kt
  16. 158
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemoteConnection.kt
  17. 159
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemotePreviewHost.kt
  18. 32
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RenderedFrame.kt
  19. 121
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/commands.kt
  20. 13
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/constants.kt
  21. 50
      gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils.kt
  22. 66
      gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewHostTest.kt
  23. 21
      gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestLogger.kt
  24. 43
      gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestPreviewProcess.kt
  25. 82
      gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/javaTools.kt
  26. 21
      gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/utils.kt
  27. 2
      gradle-plugins/settings.gradle.kts
  28. 4
      idea-plugin/build.gradle.kts
  29. 4
      idea-plugin/examples/desktop-project/build.gradle.kts
  30. 2
      idea-plugin/examples/desktop-project/gradle.properties
  31. 2
      idea-plugin/examples/desktop-project/gradle/wrapper/gradle-wrapper.properties
  32. 5
      idea-plugin/examples/desktop-project/src/main/kotlin/preview.kt
  33. 3
      idea-plugin/settings.gradle.kts
  34. 3
      idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewEntryPoint.kt
  35. 40
      idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewPanel.kt
  36. 42
      idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunConfigurationProducer.kt
  37. 9
      idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunLineMarkerContributor.kt
  38. 63
      idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewStateService.kt
  39. 26
      idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewToolWindow.kt
  40. 4
      idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/locationUtils.kt
  41. 4
      idea-plugin/src/main/resources/META-INF/plugin.xml

6
gradle-plugins/build.gradle.kts

@ -115,3 +115,9 @@ fun Project.configureGradlePlugin(
} }
} }
} }
tasks.register("publishToMavenLocal") {
for (subproject in subprojects) {
dependsOn(subproject.tasks.named("publishToMavenLocal"))
}
}

43
gradle-plugins/buildSrc/src/main/kotlin/gradleUtils.kt

@ -3,8 +3,51 @@
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/ */
import org.gradle.api.JavaVersion
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.plugins.JavaPlugin
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import java.io.File
inline fun <reified T> Project.configureIfExists(fn: T.() -> Unit) { inline fun <reified T> Project.configureIfExists(fn: T.() -> Unit) {
extensions.findByType(T::class.java)?.fn() extensions.findByType(T::class.java)?.fn()
} }
val javaHomeForTests: String? = when {
// __COMPOSE_NATIVE_DISTRIBUTIONS_MIN_JAVA_VERSION__
JavaVersion.current() >= JavaVersion.VERSION_15 -> System.getProperty("java.home")
else -> System.getenv("JDK_15")
?: System.getenv("JDK_FOR_GRADLE_TESTS")
}
val isWindows = DefaultNativePlatform.getCurrentOperatingSystem().isWindows
fun Test.configureJavaForComposeTest() {
if (javaHomeForTests != null) {
val executableFileName = if (isWindows) "java.exe" else "java"
executable = File(javaHomeForTests).resolve("bin/$executableFileName").absolutePath
} else {
doFirst { error("Use JDK 15+ to run tests or set up JDK_15/JDK_FOR_GRADLE_TESTS env. var") }
}
}
fun Project.configureJUnit() {
fun DependencyHandler.testImplementation(notation: Any) =
add(JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME, notation)
dependencies {
testImplementation(platform("org.junit:junit-bom:5.7.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
}
}
}

14
gradle-plugins/compose-preview-runtime/build.gradle.kts

@ -1,14 +0,0 @@
plugins {
kotlin("jvm")
id("maven-publish")
}
mavenPublicationConfig {
displayName = "Compose Desktop Preview Runtime"
description = "Runtime helpers for Compose Desktop Preview"
artifactId = "compose-preview-runtime-desktop"
}
dependencies {
compileOnly("org.jetbrains.kotlin:kotlin-stdlib")
}

40
gradle-plugins/compose-preview-runtime/src/main/kotlin/org/jetbrains/compose/desktop/preview/runtime/ComposePreviewRunner.kt

@ -1,40 +0,0 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.preview.runtime
import kotlin.reflect.KProperty1
class ComposePreviewRunner {
companion object {
private const val PREVIEW_ANNOTATION_FQ_NAME = "androidx.compose.ui.tooling.desktop.preview.Preview"
@JvmStatic
fun main(args: Array<String>) {
val classLoader = ComposePreviewRunner::class.java.classLoader
val previewFqName = args[0]
val previewClassFqName = previewFqName.substringBeforeLast(".")
val previewMethodName = previewFqName.substringAfterLast(".")
val previewClass = classLoader.loadClass(previewClassFqName)
val previewMethod = previewClass.methods.find { it.name == previewMethodName }
?: error("Could not find method '$previewMethodName' in class '${previewClass.canonicalName}'")
val content = previewMethod.invoke(previewClass)
val previewAnnotation = previewMethod.annotations.find { it.annotationClass.qualifiedName == PREVIEW_ANNOTATION_FQ_NAME }
?: error("Could not find '$PREVIEW_ANNOTATION_FQ_NAME' annotation on '$previewClassFqName#$previewMethodName'")
val environmentKClassProperty = previewAnnotation.annotationClass.members.find { it is KProperty1<*, *> && it.name == "environment" }
as KProperty1<Any, Class<*>>
val environmentClass = environmentKClassProperty.get(previewAnnotation)
val previewEnvironment = environmentClass
.getDeclaredConstructor()
.newInstance()
val showMethod = previewEnvironment.javaClass
.methods.find { it.name == "show" }
?: error("Could not find 'show' in class '${environmentClass.canonicalName}'")
showMethod.invoke(previewEnvironment, content)
}
}
}

17
gradle-plugins/compose/build.gradle.kts

@ -35,6 +35,8 @@ dependencies {
compileOnly(localGroovy()) compileOnly(localGroovy())
compileOnly(kotlin("gradle-plugin-api")) compileOnly(kotlin("gradle-plugin-api"))
compileOnly(kotlin("gradle-plugin")) compileOnly(kotlin("gradle-plugin"))
implementation(project(":preview-rpc"))
testImplementation(gradleTestKit()) testImplementation(gradleTestKit())
testImplementation(platform("org.junit:junit-bom:5.7.0")) testImplementation(platform("org.junit:junit-bom:5.7.0"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@ -97,22 +99,13 @@ fun testGradleVersion(gradleVersion: String) {
} }
} }
configureJUnit()
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
useJUnitPlatform() configureJavaForComposeTest()
testLogging {
events("passed", "skipped", "failed")
}
dependsOn("publishToMavenLocal") dependsOn("publishToMavenLocal")
systemProperty("compose.plugin.version", BuildProperties.deployVersion(project)) systemProperty("compose.plugin.version", BuildProperties.deployVersion(project))
if (javaHomeForTests != null) {
val executableFileName = if (isWindows) "java.exe" else "java"
executable = File(javaHomeForTests).resolve("bin/$executableFileName").absolutePath
} else {
doFirst { error("Use JDK 15+ to run tests or set up JDK_15/JDK_FOR_GRADLE_TESTS env. var") }
}
} }
task("printAllAndroidxReplacements") { task("printAllAndroidxReplacements") {

1
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt

@ -115,6 +115,7 @@ class ComposePlugin : Plugin<Project> {
val material get() = composeDependency("org.jetbrains.compose.material:material") val material get() = composeDependency("org.jetbrains.compose.material:material")
val runtime get() = composeDependency("org.jetbrains.compose.runtime:runtime") val runtime get() = composeDependency("org.jetbrains.compose.runtime:runtime")
val ui get() = composeDependency("org.jetbrains.compose.ui:ui") val ui get() = composeDependency("org.jetbrains.compose.ui:ui")
val uiTooling get() = composeDependency("org.jetbrains.compose.ui:ui-tooling")
val materialIconsExtended get() = composeDependency("org.jetbrains.compose.material:material-icons-extended") val materialIconsExtended get() = composeDependency("org.jetbrains.compose.material:material-icons-extended")
val web: WebDependencies get() = val web: WebDependencies get() =
if (ComposeBuildConfig.isComposeWithWeb) WebDependencies if (ComposeBuildConfig.isComposeWithWeb) WebDependencies

8
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt

@ -17,8 +17,8 @@ import org.jetbrains.compose.desktop.application.dsl.Application
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.internal.validation.validatePackageVersions import org.jetbrains.compose.desktop.application.internal.validation.validatePackageVersions
import org.jetbrains.compose.desktop.application.tasks.* import org.jetbrains.compose.desktop.application.tasks.*
import org.jetbrains.compose.desktop.preview.internal.configureRunPreviewTask import org.jetbrains.compose.desktop.preview.internal.configureConfigureDesktopPreviewTask
import org.jetbrains.compose.desktop.preview.tasks.AbstractRunComposePreviewTask import org.jetbrains.compose.desktop.preview.tasks.AbstractConfigureDesktopPreviewTask
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import java.io.File import java.io.File
@ -158,8 +158,8 @@ internal fun Project.configurePackagingTasks(apps: Collection<Application>) {
configureRunTask(app) configureRunTask(app)
} }
val runPreview = project.tasks.composeTask<AbstractRunComposePreviewTask>("runComposeDesktopPreview") { val configureDesktopPreviewTask = project.tasks.composeTask<AbstractConfigureDesktopPreviewTask>("configureDesktopPreview") {
configureRunPreviewTask(app) configureConfigureDesktopPreviewTask(app)
} }
} }
} }

13
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/internal/configurePreview.kt

@ -1,25 +1,18 @@
package org.jetbrains.compose.desktop.preview.internal package org.jetbrains.compose.desktop.preview.internal
import org.gradle.api.Project import org.gradle.api.Project
import org.jetbrains.compose.composeVersion
import org.jetbrains.compose.desktop.application.dsl.Application import org.jetbrains.compose.desktop.application.dsl.Application
import org.jetbrains.compose.desktop.application.internal.javaHomeOrDefault import org.jetbrains.compose.desktop.application.internal.javaHomeOrDefault
import org.jetbrains.compose.desktop.application.internal.provider import org.jetbrains.compose.desktop.application.internal.provider
import org.jetbrains.compose.desktop.preview.tasks.AbstractRunComposePreviewTask import org.jetbrains.compose.desktop.preview.tasks.AbstractConfigureDesktopPreviewTask
internal const val PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION = "composeDesktopPreviewRuntimeClasspath"
private val COMPOSE_PREVIEW_RUNTIME_DEPENDENCY = "org.jetbrains.compose:compose-preview-runtime-desktop:$composeVersion"
fun Project.initializePreview() { fun Project.initializePreview() {
configurations.create(PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION).defaultDependencies { deps ->
deps.add(dependencies.create(COMPOSE_PREVIEW_RUNTIME_DEPENDENCY))
}
} }
internal fun AbstractRunComposePreviewTask.configureRunPreviewTask(app: Application) { internal fun AbstractConfigureDesktopPreviewTask.configureConfigureDesktopPreviewTask(app: Application) {
app._configurationSource?.let { configSource -> app._configurationSource?.let { configSource ->
dependsOn(configSource.jarTaskName) dependsOn(configSource.jarTaskName)
classpath = configSource.runtimeClasspath(project) previewClasspath = configSource.runtimeClasspath(project)
javaHome.set(provider { app.javaHomeOrDefault() }) javaHome.set(provider { app.javaHomeOrDefault() })
jvmArgs.set(provider { app.jvmArgs }) jvmArgs.set(provider { app.jvmArgs })
} }

82
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractConfigureDesktopPreviewTask.kt

@ -0,0 +1,82 @@
package org.jetbrains.compose.desktop.preview.tasks
import org.gradle.api.file.FileCollection
import org.gradle.api.logging.Logger as GradleLogger
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.*
import org.jetbrains.compose.ComposeBuildConfig
import org.jetbrains.compose.desktop.application.internal.javaExecutable
import org.jetbrains.compose.desktop.application.internal.notNullProperty
import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.*
import java.io.File
abstract class AbstractConfigureDesktopPreviewTask : AbstractComposeDesktopTask() {
@get:InputFiles
internal lateinit var previewClasspath: FileCollection
@get:Internal
internal val javaHome: Property<String> = objects.notNullProperty<String>().apply {
set(providers.systemProperty("java.home"))
}
// todo
@get:Input
@get:Optional
internal val jvmArgs: ListProperty<String> = objects.listProperty(String::class.java)
@get:Input
internal val previewTarget: Provider<String> =
project.providers.gradleProperty("compose.desktop.preview.target")
@get:Input
internal val idePort: Provider<String> =
project.providers.gradleProperty("compose.desktop.preview.ide.port")
@get:InputFiles
internal val hostClasspath = project.configurations.detachedConfiguration(
project.dependencies.create("org.jetbrains.compose:preview-rpc:${ComposeBuildConfig.VERSION}")
)
@TaskAction
fun run() {
val hostConfig = PreviewHostConfig(
javaExecutable = javaExecutable(javaHome.get()),
hostClasspath = hostClasspath.files.pathString()
)
val previewClasspathString = previewClasspath.files.pathString()
val gradleLogger = logger
val previewLogger = GradlePreviewLoggerAdapter(gradleLogger)
val connection = getLocalConnectionOrNull(idePort.get().toInt(), previewLogger, onClose = {})
if (connection != null) {
connection.use {
connection.sendConfigFromGradle(
hostConfig,
previewClasspath = previewClasspathString,
previewFqName = previewTarget.get()
)
}
} else {
gradleLogger.error("Could not connect to IDE")
}
}
private fun Collection<File>.pathString(): String =
joinToString(File.pathSeparator) { it.absolutePath }
private class GradlePreviewLoggerAdapter(
private val logger: GradleLogger
) : PreviewLogger() {
// todo: support compose.verbose
override val isEnabled: Boolean
get() = logger.isDebugEnabled
override fun log(s: String) {
logger.info("Compose Preview: $s")
}
}
}

39
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractRunComposePreviewTask.kt

@ -1,39 +0,0 @@
package org.jetbrains.compose.desktop.preview.tasks
import org.gradle.api.file.FileCollection
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import org.jetbrains.compose.desktop.application.internal.javaExecutable
import org.jetbrains.compose.desktop.application.internal.notNullProperty
import org.jetbrains.compose.desktop.preview.internal.PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION
import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask
abstract class AbstractRunComposePreviewTask : AbstractComposeDesktopTask() {
@get:InputFiles
internal lateinit var classpath: FileCollection
@get:InputFiles
internal val previewRuntimeClasspath: FileCollection
get() = project.configurations.getByName(PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION)
@get:Internal
internal val javaHome: Property<String> = objects.notNullProperty<String>().apply {
set(providers.systemProperty("java.home"))
}
@get:Input
@get:Optional
internal val jvmArgs: ListProperty<String> = objects.listProperty(String::class.java)
@TaskAction
fun run() {
val target = project.findProperty("compose.desktop.preview.target") as String
execOperations.javaexec { javaExec ->
javaExec.executable = javaExecutable(javaHome.get())
javaExec.main = "org.jetbrains.compose.desktop.preview.runtime.ComposePreviewRunner"
javaExec.classpath = previewRuntimeClasspath + classpath
javaExec.args = listOf(target)
}
}
}

32
gradle-plugins/preview-rpc/build.gradle.kts

@ -0,0 +1,32 @@
plugins {
kotlin("jvm")
id("maven-publish")
}
mavenPublicationConfig {
displayName = "Compose Preview RPC"
description = "Compose Preview RPC"
artifactId = "preview-rpc"
}
dependencies {
implementation(kotlin("stdlib"))
}
configureJUnit()
tasks.test.configure {
configureJavaForComposeTest()
val runtimeClasspath = configurations.runtimeClasspath
dependsOn(runtimeClasspath)
val jar = tasks.jar
dependsOn(jar)
doFirst {
val rpcClasspath = LinkedHashSet<File>()
rpcClasspath.add(jar.get().archiveFile.get().asFile)
rpcClasspath.addAll(runtimeClasspath.get().files)
val classpathString = rpcClasspath.joinToString(File.pathSeparator) { it.absolutePath }
systemProperty("org.jetbrains.compose.test.rpc.classpath", classpathString)
}
}

38
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/Command.kt

@ -0,0 +1,38 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
data class Command(val type: Type, val args: List<String>) {
enum class Type {
ATTACH,
FRAME,
PREVIEW_CONFIG,
PREVIEW_CLASSPATH,
PREVIEW_FQ_NAME,
FRAME_REQUEST
}
constructor(type: Type, vararg args: String) : this(type, args.toList())
fun asString() =
(sequenceOf(type.name) + args.asSequence()).joinToString(" ")
companion object {
private val typeByName: Map<String, Type> =
Type.values().associateBy { it.name }
fun fromString(s: String): Command? {
val wordsIt = s.splitToSequence(" ").iterator()
val cmdName = wordsIt.nextOrNull() ?: return null
val type = typeByName[cmdName] ?: return null
val args = arrayListOf<String>()
wordsIt.forEachRemaining {
args.add(it)
}
return Command(type, args)
}
}
}

11
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/ExitCodes.kt

@ -0,0 +1,11 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
internal object ExitCodes {
const val OK = 0
const val COULD_NOT_CONNECT_TO_PREVIEW_MANAGER = 1
}

36
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewLogger.kt

@ -0,0 +1,36 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
import java.io.PrintStream
abstract class PreviewLogger {
inline operator fun invoke(s: () -> String) {
if (isEnabled) {
log(s())
}
}
inline fun error(msg: () -> String) {
invoke { "error: ${msg()}" }
}
abstract val isEnabled: Boolean
abstract fun log(s: String)
}
internal class PrintStreamLogger(
private val prefix: String,
private val printStream: PrintStream = System.out
) : PreviewLogger() {
override val isEnabled: Boolean = true
override fun log(s: String) {
printStream.print(prefix)
printStream.print(":")
printStream.println(s)
}
}

253
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewManager.kt

@ -0,0 +1,253 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
import java.io.IOException
import java.net.ServerSocket
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.system.measureTimeMillis
data class PreviewHostConfig(
val javaExecutable: String,
val hostClasspath: String
)
data class FrameConfig(val width: Int, val height: Int, val scale: Double?) {
val scaledWidth: Int get() = scaledValue(width)
val scaledHeight: Int get() = scaledValue(height)
private fun scaledValue(value: Int): Int =
if (scale != null) (value.toDouble() * scale).toInt() else value
}
data class FrameRequest(
val composableFqName: String,
val frameConfig: FrameConfig
)
interface PreviewManager {
val gradleCallbackPort: Int
fun updateFrameConfig(frameConfig: FrameConfig)
fun close()
}
private data class RunningPreview(
val connection: RemoteConnection,
val process: Process
) {
val isAlive: Boolean
get() = connection.isAlive && process.isAlive
}
class PreviewManagerImpl(private val onNewFrame: (RenderedFrame) -> Unit) : PreviewManager {
private val log = PrintStreamLogger("SERVER")
private val previewSocket = newServerSocket()
private val gradleCallbackSocket = newServerSocket()
private val connectionNumber = AtomicLong(0)
private val isAlive = AtomicBoolean(true)
// todo: restart when configuration changes
private val previewHostConfig = AtomicReference<PreviewHostConfig>(null)
private val previewClasspath = AtomicReference<String>(null)
private val previewFqName = AtomicReference<String>(null)
private val previewFrameConfig = AtomicReference<FrameConfig>(null)
private val frameRequest = AtomicReference<FrameRequest>(null)
private val shouldRequestFrame = AtomicBoolean(false)
private val runningPreview = AtomicReference<RunningPreview>(null)
private val threads = arrayListOf<Thread>()
private val runPreviewThread = repeatWhileAliveThread("runPreview") {
fun startPreviewProcess(config: PreviewHostConfig): Process =
ProcessBuilder(
config.javaExecutable,
"-Djava.awt.headless=true",
"-classpath",
config.hostClasspath,
PREVIEW_HOST_CLASS_NAME,
previewSocket.localPort.toString()
).apply {
// todo: non verbose mode
redirectOutput(ProcessBuilder.Redirect.INHERIT)
redirectError(ProcessBuilder.Redirect.INHERIT)
}.start()
val runningPreview = runningPreview.get()
val previewConfig = previewHostConfig.get()
if (previewConfig != null && runningPreview?.isAlive != true) {
val process = startPreviewProcess(previewConfig)
val connection = tryAcceptConnection(previewSocket, "PREVIEW")
connection?.receiveAttach {
this.runningPreview.set(RunningPreview(connection, process))
}
}
}
private val sendPreviewRequestThread = repeatWhileAliveThread("sendPreviewRequest") {
withLivePreviewConnection {
val classpath = previewClasspath.get()
val fqName = previewFqName.get()
val frameConfig = previewFrameConfig.get()
if (classpath != null && frameConfig != null && fqName != null) {
val request = FrameRequest(fqName, frameConfig)
if (shouldRequestFrame.get() && frameRequest.get() == null) {
if (shouldRequestFrame.compareAndSet(true, false)) {
if (frameRequest.compareAndSet(null, request)) {
sendPreviewRequest(classpath, request)
} else {
shouldRequestFrame.compareAndSet(false, true)
}
}
}
}
}
}
private val receivePreviewResponseThread = repeatWhileAliveThread("receivePreviewResponse") {
withLivePreviewConnection {
receiveFrame { renderedFrame ->
frameRequest.get()?.let { request ->
frameRequest.compareAndSet(request, null)
}
onNewFrame(renderedFrame)
}
}
}
private val gradleCallbackThread = repeatWhileAliveThread("gradleCallback") {
tryAcceptConnection(gradleCallbackSocket, "GRADLE_CALLBACK")?.let { connection ->
while (isAlive.get() && connection.isAlive) {
connection.receiveConfigFromGradle(
onPreviewClasspath = { previewClasspath.set(it) },
onPreviewHostConfig = { previewHostConfig.set(it) },
onPreviewFqName = { previewFqName.set(it) }
)
shouldRequestFrame.set(true)
sendPreviewRequestThread.interrupt()
}
}
}
override fun close() {
if (!isAlive.compareAndSet(true, false)) return
closeService("PREVIEW MANAGER") {
val runningPreview = runningPreview.getAndSet(null)
val previewConnection = runningPreview?.connection
val previewProcess = runningPreview?.process
threads.forEach { it.interrupt() }
closeService("PREVIEW HOST CONNECTION") { previewConnection?.close() }
closeService("PREVIEW SOCKET") { previewSocket.close() }
closeService("GRADLE SOCKET") { gradleCallbackSocket.close() }
closeService("THREADS") {
for (i in 0..3) {
var aliveThreads = 0
for (t in threads) {
if (t.isAlive) {
aliveThreads++
t.interrupt()
}
}
if (aliveThreads == 0) break
else Thread.sleep(300)
}
val aliveThreads = threads.filter { it.isAlive }
if (aliveThreads.isNotEmpty()) {
error("Could not stop threads: ${aliveThreads.joinToString(", ") { it.name }}")
}
}
closeService("PREVIEW HOST PROCESS") {
previewProcess?.let { process ->
if (!process.waitFor(5, TimeUnit.SECONDS)) {
log { "FORCIBLY DESTROYING PREVIEW HOST PROCESS" }
// todo: check exit code
process.destroyForcibly()
}
}
}
}
}
private inline fun closeService(name: String, doClose: () -> Unit) {
try {
log { "CLOSING $name" }
val ms = measureTimeMillis {
doClose()
}
log { "CLOSED $name in $ms ms" }
} catch (e: Exception) {
log.error { "ERROR CLOSING $name: ${e.stackTraceString}" }
}
}
override fun updateFrameConfig(frameConfig: FrameConfig) {
previewFrameConfig.set(frameConfig)
shouldRequestFrame.set(true)
sendPreviewRequestThread.interrupt()
}
override val gradleCallbackPort: Int
get() = gradleCallbackSocket.localPort
private fun tryAcceptConnection(
serverSocket: ServerSocket, socketType: String
): RemoteConnection? {
while (isAlive.get()) {
try {
val socket = serverSocket.accept()
return RemoteConnectionImpl(
socket = socket,
log = PrintStreamLogger("CONNECTION ($socketType) #${connectionNumber.incrementAndGet()}"),
onClose = {
// todo
}
)
} catch (e: IOException) {
if (e !is SocketTimeoutException) {
if (isAlive.get()) {
log.error { e.stackTraceToString() }
}
}
}
}
return null
}
private inline fun withLivePreviewConnection(fn: RemoteConnection.() -> Unit) {
val runningPreview = runningPreview.get() ?: return
if (runningPreview.isAlive) {
runningPreview.connection.fn()
}
}
private inline fun repeatWhileAliveThread(
name: String,
sleepDelayMs: Long = DEFAULT_SLEEP_DELAY_MS,
crossinline fn: () -> Unit
) = thread(name = name, start = false) {
while (isAlive.get()) {
try {
fn()
Thread.sleep(sleepDelayMs)
} catch (e: InterruptedException) {
continue
} catch (e: Throwable) {
e.printStackTrace(System.err)
break
}
}
}.also {
threads.add(it)
it.start()
}
}

158
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemoteConnection.kt

@ -0,0 +1,158 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
import java.io.*
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean
fun getLocalConnectionOrNull(
port: Int, logger: PreviewLogger, onClose: () -> Unit
): RemoteConnection? =
getLocalSocketOrNull(port, trials = 3, trialDelay = 500)?.let { socket ->
RemoteConnectionImpl(socket, logger, onClose)
}
abstract class RemoteConnection : AutoCloseable {
abstract val isAlive: Boolean
abstract fun receiveCommand(onResult: (Command) -> Unit)
abstract fun receiveData(onResult: (ByteArray) -> Unit)
abstract fun sendCommand(command: Command)
abstract fun sendData(data: ByteArray)
fun receiveUtf8StringData(onResult: (String) -> Unit) {
receiveData { bytes ->
val string = bytes.toString(Charsets.UTF_8)
onResult(string)
}
}
fun sendUtf8StringData(string: String) {
sendData(string.toByteArray(Charsets.UTF_8))
}
fun sendCommand(type: Command.Type, vararg args: String) {
sendCommand(Command(type, *args))
}
}
internal class RemoteConnectionImpl(
private val socket: Socket,
private val log: PreviewLogger,
private val onClose: () -> Unit
): RemoteConnection() {
init {
socket.soTimeout = SOCKET_TIMEOUT_MS
}
private val input = DataInputStream(socket.getInputStream())
private val output = DataOutputStream(socket.getOutputStream())
private var isConnectionAlive = AtomicBoolean(true)
override val isAlive: Boolean
get() = !socket.isClosed && isConnectionAlive.get()
private inline fun ifAlive(fn: () -> Unit) {
if (isAlive) {
fn()
}
}
override fun close() {
if (isConnectionAlive.compareAndSet(true, false)) {
log { "CLOSING" }
socket.close()
onClose()
log { "CLOSED" }
}
}
override fun sendCommand(command: Command) = ifAlive {
val commandStr = command.asString()
val data = commandStr.toByteArray()
writeData(output, data, maxDataSize = MAX_CMD_SIZE)
log { "SENT COMMAND '$commandStr'" }
}
override fun sendData(data: ByteArray) = ifAlive {
writeData(output, data, maxDataSize = MAX_BINARY_SIZE)
log { "SENT DATA [${data.size}]" }
}
override fun receiveCommand(onResult: (Command) -> Unit) = ifAlive {
val line = readData(input, MAX_CMD_SIZE)?.toString(Charsets.UTF_8)
if (line != null) {
val cmd = Command.fromString(line)
if (cmd == null) {
log { "GOT UNKNOWN COMMAND '$line'" }
} else {
log { "GOT COMMAND '$line'" }
onResult(cmd)
}
} else {
close()
}
}
override fun receiveData(onResult: (ByteArray) -> Unit) = ifAlive {
val data = readData(input, MAX_BINARY_SIZE)
if (data != null) {
log { "GOT [${data.size}]" }
onResult(data)
} else {
close()
}
}
private fun writeData(output: DataOutputStream, data: ByteArray, maxDataSize: Int): Boolean {
if (!isAlive) return false
return try {
val size = data.size
assert(size < maxDataSize) { "Data is too big: $size >= $maxDataSize" }
output.writeInt(size)
var index = 0
val bufSize = minOf(MAX_BUF_SIZE, size)
while (index < size) {
val len = minOf(bufSize, size - index)
output.write(data, index, len)
index += len
}
output.flush()
true
} catch (e: IOException) {
false
}
}
private fun readData(input: DataInputStream, maxDataSize: Int): ByteArray? {
while (isAlive) {
try {
val size = input.readInt()
if (size == -1) {
break
} else {
assert(size < maxDataSize) { "Data is too big: $size >= $maxDataSize" }
val bytes = ByteArray(size)
val bufSize = minOf(size, MAX_BUF_SIZE)
var index = 0
while (index < size) {
val len = minOf(bufSize, size - index)
val bytesRead = input.read(bytes, index, len)
index += bytesRead
}
return bytes
}
} catch (e: IOException) {
if (e !is SocketTimeoutException) break
}
}
return null
}
}

159
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemotePreviewHost.kt

@ -0,0 +1,159 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
import java.io.File
import java.lang.RuntimeException
import java.lang.reflect.Method
import java.net.SocketTimeoutException
import java.net.URLClassLoader
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
import kotlin.time.DurationUnit
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
val PREVIEW_HOST_CLASS_NAME: String
get() = PreviewHost::class.java.canonicalName
private class PreviewClassloaderProvider {
private var currentClasspath = arrayOf<File>()
private var currentSnapshots = hashSetOf<Snapshot>()
private var currentClassloader = URLClassLoader(emptyArray())
// todo: read in memory on Windows
fun getClassloader(classpathString: String): ClassLoader {
val newClasspath = classpathString.split(File.pathSeparator)
.map { File(it) }
.toTypedArray()
val newSnapshots = newClasspath.mapTo(HashSet()) { Snapshot(it) }
if (!currentClasspath.contentEquals(newClasspath) || newSnapshots != currentSnapshots) {
currentClasspath = newClasspath
currentSnapshots = newSnapshots
currentClassloader.close()
currentClassloader = URLClassLoader(Array(newClasspath.size) { newClasspath[it].toURI().toURL() })
}
return currentClassloader
}
private data class Snapshot(val file: File, val lastModified: Long, val size: Long) {
constructor(file: File) : this(file, file.lastModified(), file.length())
}
}
internal class PreviewHost(private val log: PreviewLogger, connection: RemoteConnection) {
private val previewClasspath = AtomicReference<String>(null)
private val previewRequest = AtomicReference<FrameRequest>(null)
private val classloaderProvider = PreviewClassloaderProvider()
init {
connection.sendAttach()
}
private val senderThread = thread {
while (connection.isAlive) {
try {
val classpath = previewClasspath.get()
val request = previewRequest.get()
if (classpath != null && request != null) {
if (previewRequest.compareAndSet(request, null)) {
val bytes = renderFrame(classpath, request)
val config = request.frameConfig
val frame = RenderedFrame(bytes, width = config.width, height = config.height)
connection.sendFrame(frame)
}
}
Thread.sleep(DEFAULT_SLEEP_DELAY_MS)
} catch (e: InterruptedException) {
continue
}
}
}
val receiverThread = thread {
try {
while (connection.isAlive) {
try {
connection.receivePreviewRequest(
onPreviewClasspath = {
previewClasspath.set(it)
senderThread.interrupt()
},
onFrameRequest = {
previewRequest.set(it)
senderThread.interrupt()
}
)
} catch (e: SocketTimeoutException) {
continue
}
}
} catch (e: Throwable) {
e.printStackTrace(System.err)
exitProcess(1)
}
}
fun join() {
senderThread.join()
receiverThread.join()
}
private fun renderFrame(
classpath: String,
request: FrameRequest
): ByteArray {
val classloader = classloaderProvider.getClassloader(classpath)
val previewFacade = classloader.loadClass(PREVIEW_FACADE_CLASS_NAME)
val renderArgsClasses = arrayOf(
String::class.java,
Int::class.java,
Int::class.java,
java.lang.Double::class.java
)
val render = try {
previewFacade.getMethod("render", *renderArgsClasses)
} catch (e: NoSuchMethodException) {
val signature =
"${previewFacade.canonicalName}#render(${renderArgsClasses.joinToString(", ") { it.simpleName }})"
val possibleCandidates = previewFacade.methods.filter { it.name == "render" }
throw RuntimeException("Could not find method '$signature'. Possible candidates: \n${possibleCandidates.joinToString("\n") { "* ${it}" }}", e)
}
val (fqName, frameConfig) = request
val scaledWidth = frameConfig.scaledWidth
val scaledHeight = frameConfig.scaledHeight
val scale = frameConfig.scale
log { "RENDERING '$fqName' ${scaledWidth}x$scaledHeight@${scale ?: 1f}" }
var bytes: ByteArray
val ms = measureTimeMillis {
bytes = render.invoke(previewFacade, fqName, scaledWidth, scaledHeight, scale) as ByteArray
}
log { "RENDERED [${bytes.size}] in $ms ms" }
return bytes
}
companion object {
private const val PREVIEW_FACADE_CLASS_NAME =
"androidx.compose.desktop.ui.tooling.preview.runtime.NonInteractivePreviewFacade"
@JvmStatic
fun main(args: Array<String>) {
val port = args[0].toInt()
val logger = PrintStreamLogger("PREVIEW_HOST")
val onClose = { exitProcess(ExitCodes.OK) }
val connection = getLocalConnectionOrNull(port, logger, onClose = onClose)
if (connection != null) {
PreviewHost(logger, connection).join()
} else {
exitProcess(ExitCodes.COULD_NOT_CONNECT_TO_PREVIEW_MANAGER)
}
}
}
}

32
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RenderedFrame.kt

@ -0,0 +1,32 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
data class RenderedFrame(
val bytes: ByteArray,
val width: Int,
val height: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as RenderedFrame
if (!bytes.contentEquals(other.bytes)) return false
if (width != other.width) return false
if (height != other.height) return false
return true
}
override fun hashCode(): Int {
var result = bytes.contentHashCode()
result = 31 * result + width
result = 31 * result + height
return result
}
}

121
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/commands.kt

@ -0,0 +1,121 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
import java.net.URLDecoder
import java.net.URLEncoder
internal fun RemoteConnection.sendAttach() {
sendCommand(Command.Type.ATTACH)
}
internal fun RemoteConnection.receiveAttach(fn: () -> Unit) {
receiveCommand { (type, _) ->
if (type == Command.Type.ATTACH) {
fn()
}
}
}
internal fun RemoteConnection.sendFrame(frame: RenderedFrame) {
sendCommand(Command.Type.FRAME, frame.width.toString(), frame.height.toString())
sendData(frame.bytes)
}
internal fun RemoteConnection.receiveFrame(fn: (RenderedFrame) -> Unit) {
receiveCommand { (type, args) ->
if (type == Command.Type.FRAME) {
receiveData { bytes ->
val (w, h) = args
val frame = RenderedFrame(bytes, width = w.toInt(), height = h.toInt())
fn(frame)
}
}
}
}
fun RemoteConnection.sendConfigFromGradle(
config: PreviewHostConfig,
previewClasspath: String,
previewFqName: String
) {
sendCommand(Command.Type.PREVIEW_CONFIG, URLEncoder.encode(config.javaExecutable, Charsets.UTF_8))
sendUtf8StringData(config.hostClasspath)
sendCommand(Command.Type.PREVIEW_CLASSPATH)
sendUtf8StringData(previewClasspath)
sendCommand(Command.Type.PREVIEW_FQ_NAME)
sendUtf8StringData(previewFqName)
}
internal fun RemoteConnection.receiveConfigFromGradle(
onPreviewClasspath: (String) -> Unit,
onPreviewFqName: (String) -> Unit,
onPreviewHostConfig: (PreviewHostConfig) -> Unit
) {
receiveCommand { (type, args) ->
when (type) {
Command.Type.PREVIEW_CLASSPATH ->
receiveUtf8StringData { onPreviewClasspath(it) }
Command.Type.PREVIEW_FQ_NAME ->
receiveUtf8StringData { onPreviewFqName(it) }
Command.Type.PREVIEW_CONFIG -> {
val javaExecutable = URLDecoder.decode(args[0], Charsets.UTF_8)
receiveUtf8StringData { hostClasspath ->
val config = PreviewHostConfig(javaExecutable = javaExecutable, hostClasspath = hostClasspath)
onPreviewHostConfig(config)
}
}
else -> {
// todo
}
}
}
}
internal fun RemoteConnection.sendPreviewRequest(
previewClasspath: String,
request: FrameRequest
) {
sendCommand(Command.Type.PREVIEW_CLASSPATH)
sendData(previewClasspath.toByteArray(Charsets.UTF_8))
val (fqName, frameConfig) = request
val (w, h, scale) = frameConfig
val args = arrayListOf(fqName, w.toString(), h.toString())
if (scale != null) {
val scaleLong = java.lang.Double.doubleToRawLongBits(scale)
args.add(scaleLong.toString())
}
sendCommand(Command.Type.FRAME_REQUEST, *args.toTypedArray())
}
internal fun RemoteConnection.receivePreviewRequest(
onPreviewClasspath: (String) -> Unit,
onFrameRequest: (FrameRequest) -> Unit
) {
receiveCommand { (type, args) ->
when (type) {
Command.Type.PREVIEW_CLASSPATH -> {
receiveUtf8StringData { onPreviewClasspath(it) }
}
Command.Type.FRAME_REQUEST -> {
val fqName = args.getOrNull(0)
val w = args.getOrNull(1)?.toIntOrNull()
val h = args.getOrNull(2)?.toIntOrNull()
val scale = args.getOrNull(3)?.toLongOrNull()?.let { java.lang.Double.longBitsToDouble(it) }
if (
fqName != null && fqName.isNotEmpty()
&& w != null && w > 0
&& h != null && h > 0
) {
onFrameRequest(FrameRequest(fqName, FrameConfig(width = w, height = h, scale = scale)))
}
}
else -> {
// todo
}
}
}
}

13
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/constants.kt

@ -0,0 +1,13 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
internal const val SOCKET_TIMEOUT_MS = 1000
internal const val DEFAULT_SLEEP_DELAY_MS = 1000L
internal const val MAX_CMD_SIZE = 8 * 1024
// 100 Mb should be enough even for 8K screenshots
internal const val MAX_BINARY_SIZE = 100 * 1024 * 1024
internal const val MAX_BUF_SIZE = 8 * 1024

50
gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils.kt

@ -0,0 +1,50 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.PrintStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
internal fun getLocalSocketOrNull(
port: Int,
trials: Int,
trialDelay: Long,
): Socket? {
for (i in 0..trials) {
try {
return Socket(localhost, port)
} catch (e: IOException) {
Thread.sleep(trialDelay)
}
}
return null
}
internal val localhost: InetAddress
get() = InetAddress.getLoopbackAddress()
internal fun newServerSocket() =
ServerSocket(0, 0, localhost).apply {
reuseAddress = true
soTimeout = SOCKET_TIMEOUT_MS
}
internal fun <T> Iterator<T>.nextOrNull(): T? =
if (hasNext()) next() else null
internal val Throwable.stackTraceString: String
get() {
val output = ByteArrayOutputStream()
PrintStream(output).use {
printStackTrace(it)
}
return output.toString(Charsets.UTF_8)
}

66
gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewHostTest.kt

@ -0,0 +1,66 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils.*
import org.junit.jupiter.api.*
import java.net.ServerSocket
import java.util.concurrent.atomic.*
import kotlin.concurrent.thread
internal class PreviewHostTest {
private lateinit var serverSocket: ServerSocket
@BeforeEach
internal fun setUp() {
serverSocket = ServerSocket(0, 0, localhost)
serverSocket.soTimeout = 10.secondsAsMillis
}
@AfterEach
internal fun tearDown() {
serverSocket.close()
}
@Test
fun connectNormallyAndStop() {
withPreviewHostConnection { connection ->
connection.receiveCommand { command ->
check(command.type == Command.Type.ATTACH) {
"First received command is not ${Command.Type.ATTACH}: ${command.asString()}"
}
}
}
}
private fun withPreviewHostConnection(doWithConnection: (RemoteConnection) -> Unit) {
val isServerConnectionClosed = AtomicBoolean(false)
val serverThread = thread {
val socket = serverSocket.accept()
val logger = TestLogger()
val connection = RemoteConnectionImpl(socket, logger, onClose = {
isServerConnectionClosed.set(true)
})
doWithConnection(connection)
connection.close()
}
val serverThreadFailure = AtomicReference<Throwable>(null)
serverThread.setUncaughtExceptionHandler { t, e ->
serverThreadFailure.set(e)
}
val previewHostProcess = TestPreviewProcess(serverSocket.localPort)
previewHostProcess.start()
serverThread.join(10L.secondsAsMillis)
val serverFailure = serverThreadFailure.get()
check(serverFailure == null) { "Unexpected server failure: $serverFailure" }
check(!serverThread.isAlive) { "Server thread should not be alive at this point" }
check(isServerConnectionClosed.get()) { "Server connection was not closed" }
previewHostProcess.finish()
}
}

21
gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestLogger.kt

@ -0,0 +1,21 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewLogger
internal class TestLogger : PreviewLogger() {
private val myMessages = arrayListOf<String>()
val messages: List<String>
get() = myMessages
override val isEnabled: Boolean
get() = true
override fun log(s: String) {
myMessages.add(s)
}
}

43
gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestPreviewProcess.kt

@ -0,0 +1,43 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PREVIEW_HOST_CLASS_NAME
import java.util.concurrent.TimeUnit
class TestPreviewProcess(private val port: Int) {
private var myProcess: Process? = null
fun start() {
if (myProcess != null) error("Process was started already")
myProcess = runJava(
headless = true,
classpath = previewTestClaspath,
args = listOf(PREVIEW_HOST_CLASS_NAME, port.toString())
).start()
}
fun finish() {
val process = myProcess
check(process != null) { "Process was not started" }
process.waitFor(10, TimeUnit.SECONDS)
if (process.isAlive) {
val jstackOutput = runJStackAndGetOutput(process.pid())
val message = buildString {
appendLine("Preview host process did not stop:")
jstackOutput.splitToSequence("\n").forEach {
appendLine("> $it")
}
}
process.destroyForcibly()
error(message)
}
val exitCode = process.exitValue()
check(exitCode == 0) { "Non-zero exit code: $exitCode" }
}
}

82
gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/javaTools.kt

@ -0,0 +1,82 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
import java.io.File
import java.util.concurrent.TimeUnit
internal fun runJava(
headless: Boolean = true,
debugPort: Int? = null,
classpath: String = "",
args: List<String> = emptyList()
): ProcessBuilder {
val javaExec = javaToolPath("java")
val cmd = arrayListOf(
javaExec,
"-classpath",
classpath
)
if (headless) {
cmd.add("-Djava.awt.headless=true")
}
if (debugPort != null) {
cmd.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$debugPort")
}
cmd.addAll(args)
println("Starting process: [${cmd.joinToString(",") { "\n $it" } }\n]")
return ProcessBuilder(cmd).apply {
redirectError(ProcessBuilder.Redirect.INHERIT)
redirectOutput(ProcessBuilder.Redirect.INHERIT)
}
}
internal fun runJStackAndGetOutput(
pid: Long
): String {
val jstack = javaToolPath("jstack")
val stdoutFile = File.createTempFile("jstack-stdout", ".txt").apply { deleteOnExit() }
val stderrFile = File.createTempFile("jstack-stderr", ".txt").apply { deleteOnExit() }
try {
val process = ProcessBuilder(jstack, pid.toString()).apply {
redirectOutput(stdoutFile)
redirectError(stderrFile)
}.start()
process.waitFor(10, TimeUnit.SECONDS)
if (process.isAlive) {
process.destroyForcibly()
error("jstack did not finish")
}
val exitCode = process.exitValue()
check(exitCode == 0) {
buildString {
appendLine("jstack finished with error: $exitCode")
appendLine(" output:")
stdoutFile.readLines().forEach {
appendLine(" >")
}
appendLine(" err:")
stderrFile.readLines().forEach {
appendLine(" >")
}
}
" $exitCode\n${stderrFile.readText()}"
}
return stdoutFile.readText()
} finally {
stdoutFile.delete()
stderrFile.delete()
}
}
private fun javaToolPath(toolName: String): String {
val javaHome = File(systemProperty("java.home"))
val toolExecutableName = if (isWindows) "$toolName.exe" else toolName
val executable = javaHome.resolve("bin/$toolExecutableName")
check(executable.isFile) { "Could not find tool '$toolName' at specified path: $executable" }
return executable.absolutePath
}

21
gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/utils.kt

@ -0,0 +1,21 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
internal fun systemProperty(name: String): String =
System.getProperty(name) ?: error("System property is not found: '$name'")
internal val isWindows =
systemProperty("os.name").startsWith("windows", ignoreCase = true)
internal val previewTestClaspath: String
get() = systemProperty("org.jetbrains.compose.test.rpc.classpath")
internal val Int.secondsAsMillis: Int
get() = this * 1000
internal val Long.secondsAsMillis: Long
get() = this * 1000

2
gradle-plugins/settings.gradle.kts

@ -5,4 +5,4 @@ pluginManagement {
} }
include(":compose") include(":compose")
include(":compose-preview-runtime") include(":preview-rpc")

4
idea-plugin/build.gradle.kts

@ -16,6 +16,10 @@ repositories {
mavenCentral() mavenCentral()
} }
dependencies {
implementation("org.jetbrains.compose:preview-rpc")
}
intellij { intellij {
pluginName = "Compose Multiplatform IDE Support" pluginName = "Compose Multiplatform IDE Support"
type = properties("platform.type") type = properties("platform.type")

4
idea-plugin/examples/desktop-project/build.gradle.kts

@ -3,7 +3,7 @@ import org.jetbrains.compose.compose
plugins { plugins {
// __KOTLIN_COMPOSE_VERSION__ // __KOTLIN_COMPOSE_VERSION__
kotlin("jvm") version "1.5.10" kotlin("jvm") version "1.5.10"
id("org.jetbrains.compose") version "0.4.0-idea-preview-build57" id("org.jetbrains.compose") version "0.0.0-non-interactive-preview-build89-4"
} }
repositories { repositories {
@ -12,7 +12,7 @@ repositories {
} }
dependencies { dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4") implementation(compose.uiTooling)
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
} }

2
idea-plugin/examples/desktop-project/gradle.properties

@ -1,2 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.code.style=official kotlin.code.style=official
#org.gradle.unsafe.configuration-cache=true
#org.gradle.unsafe.configuration-cache-problems=warn

2
idea-plugin/examples/desktop-project/gradle/wrapper/gradle-wrapper.properties vendored

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

5
idea-plugin/examples/desktop-project/src/main/kotlin/preview.kt

@ -1,10 +1,11 @@
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.tooling.desktop.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
@Preview @Preview
fun examplePreview() = @Composable { @Composable
fun ExamplePreview() {
var text by remember { mutableStateOf("Hello, World!") } var text by remember { mutableStateOf("Hello, World!") }
Button(onClick = { Button(onClick = {

3
idea-plugin/settings.gradle.kts

@ -0,0 +1,3 @@
includeBuild("../gradle-plugins") {
name = "compose-gradle-components"
}

3
idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewEntryPoint.kt

@ -20,12 +20,9 @@ import com.intellij.codeInspection.reference.EntryPoint
import com.intellij.codeInspection.reference.RefElement import com.intellij.codeInspection.reference.RefElement
import com.intellij.configurationStore.deserializeInto import com.intellij.configurationStore.deserializeInto
import com.intellij.configurationStore.serializeObjectInto import com.intellij.configurationStore.serializeObjectInto
import com.intellij.openapi.util.InvalidDataException
import com.intellij.openapi.util.WriteExternalException
import com.intellij.psi.PsiElement import com.intellij.psi.PsiElement
import com.intellij.psi.PsiMethod import com.intellij.psi.PsiMethod
import org.jdom.Element import org.jdom.Element
import org.jetbrains.annotations.Nls
/** /**
* [EntryPoint] implementation to mark `@Preview` functions as entry points and avoid them being flagged as unused. * [EntryPoint] implementation to mark `@Preview` functions as entry points and avoid them being flagged as unused.

40
idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewPanel.kt

@ -0,0 +1,40 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ide.preview
import java.awt.Color
import java.awt.Dimension
import java.awt.Graphics
import java.awt.image.BufferedImage
import javax.swing.JPanel
internal class PreviewPanel : JPanel() {
private var image: BufferedImage? = null
private var imageDimension: Dimension? = null
override fun paintComponent(g: Graphics) {
super.paintComponent(g)
synchronized(this) {
image?.let { image ->
val w = imageDimension!!.width
val h = imageDimension!!.height
g.color = Color.WHITE
g.fillRect(0, 0, w, h)
g.drawImage(image, 0, 0, w, h, null)
}
}
}
fun previewImage(image: BufferedImage, imageDimension: Dimension) {
synchronized(this) {
this.image = image
this.imageDimension = imageDimension
}
repaint()
}
}

42
idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunConfigurationProducer.kt

@ -17,8 +17,10 @@
package org.jetbrains.compose.desktop.ide.preview package org.jetbrains.compose.desktop.ide.preview
import com.intellij.execution.actions.ConfigurationContext import com.intellij.execution.actions.ConfigurationContext
import com.intellij.execution.actions.ConfigurationFromContext
import com.intellij.execution.actions.LazyRunConfigurationProducer import com.intellij.execution.actions.LazyRunConfigurationProducer
import com.intellij.execution.configurations.ConfigurationFactory import com.intellij.execution.configurations.ConfigurationFactory
import com.intellij.openapi.components.service
import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil
import com.intellij.openapi.util.Ref import com.intellij.openapi.util.Ref
import com.intellij.psi.PsiElement import com.intellij.psi.PsiElement
@ -47,13 +49,13 @@ class PreviewRunConfigurationProducer : LazyRunConfigurationProducer<GradleRunCo
context: ConfigurationContext context: ConfigurationContext
): Boolean { ): Boolean {
val composeFunction = context.containingComposePreviewFunction() ?: return false val composeFunction = context.containingComposePreviewFunction() ?: return false
return configuration.run { return configuration.run {
name == composeFunction.name!! name == runConfigurationNameFor(composeFunction)
&& settings.externalProjectPath == context.modulePath() && settings.externalProjectPath == context.modulePath()
&& settings.scriptParameters.contains( && settings.taskNames.singleOrNull() == configureDesktopPreviewTaskName
previewTargetGradleArg(composeFunction.composePreviewFunctionFqn()) && settings.scriptParameters.split(" ").containsAll(
) runConfigurationScriptParameters(composeFunction.composePreviewFunctionFqn(), context.port)
)
} }
} }
@ -63,21 +65,33 @@ class PreviewRunConfigurationProducer : LazyRunConfigurationProducer<GradleRunCo
sourceElement: Ref<PsiElement> sourceElement: Ref<PsiElement>
): Boolean { ): Boolean {
val composeFunction = context.containingComposePreviewFunction() ?: return false val composeFunction = context.containingComposePreviewFunction() ?: return false
// todo: temporary configuration?
configuration.apply { configuration.apply {
name = composeFunction.name!! name = runConfigurationNameFor(composeFunction)
settings.taskNames.add("runComposeDesktopPreview") settings.taskNames.add(configureDesktopPreviewTaskName)
settings.externalProjectPath = ExternalSystemApiUtil.getExternalProjectPath(context.location?.module) settings.externalProjectPath = ExternalSystemApiUtil.getExternalProjectPath(context.location?.module)
settings.scriptParameters = listOf( settings.scriptParameters =
previewTargetGradleArg(composeFunction.composePreviewFunctionFqn()) runConfigurationScriptParameters(composeFunction.composePreviewFunctionFqn(), context.port)
).joinToString(" ") .joinToString(" ")
} }
return true return true
} }
} }
private fun previewTargetGradleArg(target: String): String = private val configureDesktopPreviewTaskName = "configureDesktopPreview"
"-Pcompose.desktop.preview.target=$target"
private fun runConfigurationNameFor(function: KtNamedFunction): String =
"Compose Preview: ${function.name!!}"
private fun runConfigurationScriptParameters(target: String, idePort: Int): List<String> =
listOf(
"-Pcompose.desktop.preview.target=$target",
"-Pcompose.desktop.preview.ide.port=${idePort}"
)
private val ConfigurationContext.port: Int
get() = project.service<PreviewStateService>().gradleCallbackPort
private fun KtNamedFunction.composePreviewFunctionFqn() = "${getClassName()}.${name}" private fun KtNamedFunction.composePreviewFunctionFqn() = "${getClassName()}.${name}"

9
idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunLineMarkerContributor.kt

@ -36,11 +36,10 @@ class PreviewRunLineMarkerContributor : RunLineMarkerContributor() {
val parent = element.parent val parent = element.parent
return when { return when {
parent is KtNamedFunction && parent.isValidComposePreview() -> parent is KtNamedFunction && parent.isValidComposePreview() -> {
Info( val actions = arrayOf(ExecutorAction.getActions(0).first())
PreviewIcons.COMPOSE, Info(PreviewIcons.COMPOSE, actions) { PreviewMessages.runPreview(parent.name!!) }
arrayOf(ExecutorAction.getActions(0).first()) }
) { PreviewMessages.runPreview(parent.name!!) }
else -> null else -> null
} }
} }

63
idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewStateService.kt

@ -0,0 +1,63 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ide.preview
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.FrameConfig
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewManager
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewManagerImpl
import java.awt.Dimension
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO
import javax.swing.JComponent
import javax.swing.event.AncestorEvent
import javax.swing.event.AncestorListener
@Service
class PreviewStateService : Disposable {
private var myPanel: PreviewPanel? = null
private val previewManager: PreviewManager = PreviewManagerImpl { frame ->
ByteArrayInputStream(frame.bytes).use { input ->
val image = ImageIO.read(input)
myPanel?.previewImage(image, Dimension(frame.width, frame.height))
}
}
val gradleCallbackPort: Int
get() = previewManager.gradleCallbackPort
private val myListener = object : AncestorListener {
private fun updateFrameSize(c: JComponent) {
val frameConfig = FrameConfig(
width = c.width,
height = c.height,
scale = null
)
previewManager.updateFrameConfig(frameConfig)
}
override fun ancestorAdded(event: AncestorEvent) {
updateFrameSize(event.component)
}
override fun ancestorRemoved(event: AncestorEvent) {
}
override fun ancestorMoved(event: AncestorEvent) {
updateFrameSize(event.component)
}
}
override fun dispose() {
myPanel?.removeAncestorListener(myListener)
previewManager.close()
}
internal fun registerPreviewPanel(panel: PreviewPanel) {
myPanel = panel
panel.addAncestorListener(myListener)
}
}

26
idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewToolWindow.kt

@ -0,0 +1,26 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.ide.preview
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
class PreviewToolWindow : ToolWindowFactory, DumbAware {
override fun isApplicable(project: Project): Boolean {
// todo: filter only Compose projects
return true
}
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
toolWindow.contentManager.let { content ->
val panel = PreviewPanel()
content.addContent(content.factory.createContent(panel, null, false))
project.service<PreviewStateService>().registerPreviewPanel(panel)
}
}
}

4
idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/locationUtils.kt

@ -27,7 +27,7 @@ import org.jetbrains.kotlin.resolve.BindingContext
import org.jetbrains.kotlin.resolve.descriptorUtil.fqNameSafe import org.jetbrains.kotlin.resolve.descriptorUtil.fqNameSafe
import org.jetbrains.kotlin.resolve.lazy.BodyResolveMode import org.jetbrains.kotlin.resolve.lazy.BodyResolveMode
internal const val DESKTOP_PREVIEW_ANNOTATION_FQN = "androidx.compose.ui.tooling.desktop.preview.Preview" internal const val DESKTOP_PREVIEW_ANNOTATION_FQN = "androidx.compose.desktop.ui.tooling.preview.Preview"
/** /**
* Utils based on functions from AOSP, taken from * Utils based on functions from AOSP, taken from
@ -49,6 +49,8 @@ internal fun KtNamedFunction.isValidComposePreview() =
* *
*/ */
internal fun KtNamedFunction.isValidPreviewLocation(): Boolean { internal fun KtNamedFunction.isValidPreviewLocation(): Boolean {
if (valueParameters.size > 0) return false
if (isTopLevel) return true if (isTopLevel) return true
if (parentOfType<KtNamedFunction>() == null) { if (parentOfType<KtNamedFunction>() == null) {

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

@ -34,5 +34,9 @@
<runConfigurationProducer <runConfigurationProducer
implementation="org.jetbrains.compose.web.ide.run.WebRunConfigurationProducer"/> implementation="org.jetbrains.compose.web.ide.run.WebRunConfigurationProducer"/>
<toolWindow
factoryClass="org.jetbrains.compose.desktop.ide.preview.PreviewToolWindow"
id="Desktop Preview" doNotActivateOnStart="true"
anchor="right" />
</extensions> </extensions>
</idea-plugin> </idea-plugin>

Loading…
Cancel
Save