[compose] IJPL-208258 Refresh indication for Compose UI Preview

GitOrigin-RevId: 87e929b554642a79ca27a39303743b99f300fb91
This commit is contained in:
Yuriy Artamonov
2025-09-26 22:27:29 +00:00
committed by intellij-monorepo-bot
parent 5c36d1af57
commit 49fd96e756
8 changed files with 283 additions and 73 deletions
@@ -34,9 +34,10 @@
<registryKey key="devkit.compose.desktop.preview" defaultValue="false"
restartRequired="true" description="Enables Compose UI previews tool window"/>
<toolWindow id="ComposeUIPreview" anchor="right"
factoryClass="com.intellij.devkit.compose.preview.ComposePreviewToolWindowFactory"
icon="org.jetbrains.idea.devkit.DevKitIcons.ComposeToolWindow"/>
<library.toolWindow id="ComposeUIPreview" anchor="right"
factoryClass="com.intellij.devkit.compose.preview.ComposePreviewToolWindowFactory"
icon="org.jetbrains.idea.devkit.DevKitIcons.ComposeToolWindow"
librarySearchClass="com.intellij.devkit.compose.preview.ComposePreviewSearcher"/>
</extensions>
<actions>
@@ -62,10 +63,14 @@
<group id="ComposePreviewActions.Title" searchable="false">
<action id="ComposePreviewRefresh"
class="com.intellij.devkit.compose.preview.ComposePreviewRefreshAction"
icon="AllIcons.General.Refresh"/>
icon="AllIcons.General.Refresh">
<override-text place="GoToAction"/>
</action>
<action id="ComposePreviewRefreshAuto"
class="com.intellij.devkit.compose.preview.ComposePreviewRefreshAutoAction"
icon="AllIcons.General.RefreshAuto"/>
icon="AllIcons.General.RefreshAuto">
<override-text place="GoToAction"/>
</action>
</group>
</actions>
</idea-plugin>
@@ -44,6 +44,7 @@ jewel.swing.not.editable.disabled=Not editable + disabled
jewel.swing.editable=Editable
jewel.swing.editable.disabled=Editable + Disabled
jewel.swing.text.areas=Text areas:
compose.sandbox=Compose Sandbox
compose.sandbox.show.automatically.on.project.open=Show automatically on project open
configurable.name.settings.page.on.compose=Settings Page on Compose
@@ -52,11 +53,17 @@ link.label.enable.hot.reload=Enable Hot Reload
link.label.dismiss=Dismiss
toolwindow.stripe.ComposeUIPreview=Compose UI Preview
compose.preview.empty.text.top=Preview will appear here.
compose.preview.empty.text.top=Preview will appear here \u1F300.
compose.preview.empty.text.compile=Make sure you compile the project at least once!
compose.preview.build=Build project
compose.preview.build=\u2014 <Build project>
compose.preview.refresh=\u2014 <Refresh>
compose.preview.unsupported.file=This file is not supported:
compose.preview.enable.composable=\u2014 Use @Composable and @Preview to enable UI preview
action.ComposePreviewRefresh.text=Refresh
action.ComposePreviewRefresh.GoToAction.text=Refresh Compose UI Preview
action.ComposePreviewRefresh.description=Refreshes UI preview
action.ComposePreviewRefreshAuto.text=Refresh Automatically
action.ComposePreviewRefreshAuto.GoToAction.text=Refresh Compose UI Preview Automatically
action.ComposePreviewRefreshAuto.description=Toggles automatic refresh on changes in code
@@ -0,0 +1,112 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.devkit.compose.preview
import com.intellij.devkit.compose.DevkitComposeBundle
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionUiKind
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys.PROJECT
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.impl.SimpleDataContext
import com.intellij.openapi.components.service
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBPanelWithEmptyText
import com.intellij.util.ui.AsyncProcessIcon
import com.intellij.util.ui.StatusText
import java.awt.BorderLayout
import javax.swing.SwingUtilities
internal class ComposePreviewBusyPanel(private val project: Project) : JBPanelWithEmptyText(BorderLayout()), DumbAware {
@Volatile
private var busy: Boolean = false
private var busyIcon: AsyncProcessIcon? = null
init {
emptyText.isCenterAlignText = false
emptyText.clear()
emptyText.appendLine(DevkitComposeBundle.message("compose.preview.empty.text.top"))
emptyText.appendLine(DevkitComposeBundle.message("compose.preview.empty.text.compile"))
emptyText.appendLine("")
appendBuildHintText(project)
}
@Suppress("HardCodedStringLiteral")
private fun appendBuildHintText(project: Project) {
val text = emptyText
val buildLine = DevkitComposeBundle.message("compose.preview.build")
text.appendLine(buildLine.substringBefore("<"))
text.appendText(buildLine.substringAfter('<').substringBefore('>'), SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES) { _ ->
val compileAction = ActionManager.getInstance().getAction("CompileDirty")!!
val dataContext = SimpleDataContext.builder().add(PROJECT, project).build()
ActionUtil.performAction(compileAction, AnActionEvent.createEvent(dataContext, Presentation(), "", ActionUiKind.NONE, null))
}
text.appendText(buildLine.substringAfter(">"))
val shortcut = ActionManager.getInstance().getKeyboardShortcut("CompileDirty")
val shortcutText = shortcut?.let { " (${KeymapUtil.getShortcutText(shortcut)})" } ?: ""
text.appendText(shortcutText)
addRefreshHintText(text, project)
}
@Suppress("HardCodedStringLiteral")
private fun addRefreshHintText(text: StatusText, project: Project) {
val refreshLine = DevkitComposeBundle.message("compose.preview.refresh")
text.appendLine(refreshLine.substringBefore("<"))
text.appendText(refreshLine.substringAfter('<').substringBefore('>'), SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES) { _ ->
project.service<ComposePreviewChangesTracker>().refresh()
}
text.appendText(refreshLine.substringAfter(">"))
}
fun setPaintBusy(paintBusy: Boolean) {
if (busy == paintBusy) return
busy = paintBusy
updateBusy()
}
private fun updateBusy() {
if (busy) {
if (busyIcon == null) {
busyIcon = AsyncProcessIcon.Big(toString())
busyIcon!!.setOpaque(false)
busyIcon!!.setPaintPassiveIcon(false)
add(busyIcon!!, BorderLayout.CENTER)
}
}
if (busyIcon != null) {
if (busy) {
busyIcon!!.resume()
}
else {
busyIcon!!.suspend()
SwingUtilities.invokeLater(Runnable {
if (busyIcon != null) {
repaint()
}
})
}
if (busyIcon != null) {
busyIcon!!.updateLocation(this)
}
}
}
fun displayUnsupportedFile() {
removeAll()
emptyText.clear()
emptyText.appendLine(AllIcons.Ide.FatalErrorRead, DevkitComposeBundle.message("compose.preview.unsupported.file"),
StatusText.DEFAULT_ATTRIBUTES, null)
emptyText.appendLine(DevkitComposeBundle.message("compose.preview.enable.composable"))
addRefreshHintText(emptyText, project)
}
}
@@ -1,6 +1,7 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.devkit.compose.preview
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.editor.Editor
@@ -17,28 +18,91 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
private val manualRefreshCounter = AtomicInteger(0)
enum class RefreshReason {
INITIAL,
CHANGE,
MANUAL {
override fun toHash(): Int {
return manualRefreshCounter.addAndGet(1)
}
};
open fun toHash(): Int = ordinal
}
private data class RefreshSignal(
val text: String?,
val file: VirtualFile,
val reason: RefreshReason,
val hash: Int,
) {
constructor(text: String?, file: VirtualFile, reason: RefreshReason) : this(text, file, reason, reason.toHash())
}
private const val REFRESH_SETTING_KEY = "compose.preview.refresh.auto"
@OptIn(FlowPreview::class)
@Service(Service.Level.PROJECT)
internal class EditorSelectionAndTextChangesTracker(val project: Project, val coroutineScope: CoroutineScope) {
internal class ComposePreviewChangesTracker(val project: Project, val coroutineScope: CoroutineScope) {
@Volatile
private var refreshCallback: (() -> Unit)? = null
private val autoRefresh: MutableStateFlow<Boolean> =
MutableStateFlow(PropertiesComponent.getInstance().getBoolean(REFRESH_SETTING_KEY, false))
fun setAutoRefresh(value: Boolean) {
autoRefresh.value = value
PropertiesComponent.getInstance().setValue(REFRESH_SETTING_KEY, value)
}
fun isAutoRefresh(): Boolean {
return autoRefresh.value
}
fun startTracking(project: Project, disposable: Disposable, processor: suspend (VirtualFile) -> Unit) {
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
observeEditorContentChanges(project, disposable)
val changesFlow = observeEditorContentChanges(project, disposable)
.debounce(1000L)
.distinctUntilChanged()
.filter { autoRefresh.value }
val manualRefreshFlow = observeManualRefresh()
merge(changesFlow, manualRefreshFlow)
.conflate() // drop older refreshes, there is no need to process them
.collect { (_, virtualFile) ->
processor(virtualFile)
}
}
}
private fun observeEditorContentChanges(project: Project, disposable: Disposable): Flow<Pair<String, VirtualFile>> {
fun refresh() {
refreshCallback?.invoke()
}
private fun observeManualRefresh(): Flow<RefreshSignal> {
return callbackFlow {
refreshCallback = {
val fileEditorManager = FileEditorManager.getInstance(project)
val selectedEditor = fileEditorManager.selectedEditor as? TextEditor
val selectedFile = fileEditorManager.selectedFiles.firstOrNull()
if (selectedEditor != null && selectedFile != null) {
trySend(RefreshSignal(null, selectedFile, RefreshReason.MANUAL))
}
}
awaitClose { }
}
}
private fun observeEditorContentChanges(project: Project, disposable: Disposable): Flow<RefreshSignal> {
return callbackFlow {
var currentEditor: Editor? = null
var documentListener: DocumentListener? = null
@@ -63,7 +127,7 @@ internal class EditorSelectionAndTextChangesTracker(val project: Project, val co
documentListener = object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
val text = event.document.text
trySend(text to file)
trySend(RefreshSignal(text, file, RefreshReason.CHANGE))
}
}
currentEditor!!.document.addDocumentListener(documentListener!!, disposable)
@@ -77,7 +141,7 @@ internal class EditorSelectionAndTextChangesTracker(val project: Project, val co
if (newFile != null) {
val text = newEditor.document.text
trySend(text to newFile)
trySend(RefreshSignal(text, newFile, RefreshReason.CHANGE))
addListenersToCurrentEditor(newEditor, newFile)
}
else {
@@ -97,7 +161,7 @@ internal class EditorSelectionAndTextChangesTracker(val project: Project, val co
val selectedFile = fileEditorManager.selectedFiles.firstOrNull()
if (selectedEditor != null && selectedFile != null) {
trySend("" to selectedFile)
trySend(RefreshSignal(null, selectedFile, RefreshReason.INITIAL))
addListenersToCurrentEditor(selectedEditor.editor, selectedFile)
}
@@ -1,11 +1,21 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.devkit.compose.preview
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAwareAction
internal class ComposePreviewRefreshAction : DumbAwareAction() {
override fun actionPerformed(e: AnActionEvent) {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = e.project != null
}
override fun actionPerformed(e: AnActionEvent) {
val p = e.project ?: return
p.service<ComposePreviewChangesTracker>().refresh()
}
}
@@ -3,18 +3,31 @@ package com.intellij.devkit.compose.preview
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAwareToggleAction
internal class ComposePreviewRefreshAutoAction : DumbAwareToggleAction() {
private var refresh: Boolean = true
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
super.update(e)
e.presentation.isEnabledAndVisible = e.project != null
}
override fun isSelected(e: AnActionEvent): Boolean {
return this.refresh
val p = e.project ?: return false
return p.service<ComposePreviewChangesTracker>().isAutoRefresh()
}
override fun setSelected(e: AnActionEvent, state: Boolean) {
this.refresh = state
val p = e.project ?: return
val tracker = p.service<ComposePreviewChangesTracker>()
tracker.setAutoRefresh(state)
if (state) {
tracker.refresh()
}
}
}
@@ -0,0 +1,13 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.devkit.compose.preview
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ext.LibrarySearchHelper
import org.jetbrains.idea.devkit.util.PsiUtil
internal class ComposePreviewSearcher : LibrarySearchHelper {
override fun isLibraryExists(project: Project): Boolean {
// only in plugin projects to not conflict with other previews
return PsiUtil.isPluginProject(project)
}
}
@@ -5,15 +5,11 @@ package com.intellij.devkit.compose.preview
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.currentCompositeKeyHashCode
import com.intellij.devkit.compose.DevkitComposeBundle
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.actionSystem.CommonDataKeys.PROJECT
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.impl.SimpleDataContext
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.getUserData
@@ -22,9 +18,6 @@ import com.intellij.openapi.util.Key
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBPanelWithEmptyText
import com.intellij.util.ui.StatusText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.withContext
@@ -40,17 +33,7 @@ internal class ComposePreviewToolWindowFactory : ToolWindowFactory, DumbAware {
@OptIn(ExperimentalJewelApi::class)
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val wrapperPanel = object : JBPanelWithEmptyText(), DumbAware {
}
wrapperPanel.setLayout(BorderLayout())
val emptyText = wrapperPanel.emptyText
emptyText.isCenterAlignText = false
emptyText.clear()
emptyText.appendLine(DevkitComposeBundle.message("compose.preview.empty.text.top"))
emptyText.appendLine(DevkitComposeBundle.message("compose.preview.empty.text.compile"))
emptyText.appendLine("")
appendBuildHintText(project, emptyText)
val wrapperPanel = ComposePreviewBusyPanel(project)
val contentManager = toolWindow.contentManager
val toolWindowContent = contentManager.factory.createContent(wrapperPanel, "", false)
@@ -61,43 +44,46 @@ internal class ComposePreviewToolWindowFactory : ToolWindowFactory, DumbAware {
val titleActionsGroup = actionManager.getAction("ComposePreviewActions.Title") as DefaultActionGroup
toolWindow.setTitleActions(titleActionsGroup.getChildren(actionManager).toList())
project.service<EditorSelectionAndTextChangesTracker>().startTracking(project, toolWindowContent) { virtualFile ->
try {
val provider = compileCode(virtualFile, project) ?: return@startTracking
project.service<ComposePreviewChangesTracker>().startTracking(project, toolWindowContent) { virtualFile ->
wrapperPanel.setPaintBusy(true)
withContext(Dispatchers.EDT) {
// free up the previous content JVM classes, register new
try {
wrapperPanel.getUserData(PROVIDER_KEY)?.classLoader?.close()
}
catch (e: Exception) {
thisLogger().error("Unable to release classloader for UI preview", e)
}
wrapperPanel.putUserData(PROVIDER_KEY, provider)
wrapperPanel.removeAll()
wrapperPanel.add(compose(focusOnClickInside = true) {
provider.build(currentComposer, currentCompositeKeyHashCode)
}, BorderLayout.CENTER)
}
val provider = try {
compileCode(virtualFile, project)
}
catch (e: Throwable) {
thisLogger().error(e)
thisLogger().warn("Unable to compile code for preview", e)
return@startTracking
}
finally {
wrapperPanel.setPaintBusy(false)
}
if (provider == null) {
withContext(Dispatchers.EDT) {
wrapperPanel.displayUnsupportedFile()
}
return@startTracking
}
withContext(Dispatchers.EDT) {
// free up the previous content JVM classes, register new
try {
wrapperPanel.getUserData(PROVIDER_KEY)?.classLoader?.close()
}
catch (e: Exception) {
thisLogger().warn("Unable to release classloader for UI preview", e)
}
wrapperPanel.putUserData(PROVIDER_KEY, provider)
wrapperPanel.setPaintBusy(false)
wrapperPanel.removeAll()
wrapperPanel.add(compose(focusOnClickInside = true) {
provider.build(currentComposer, currentCompositeKeyHashCode)
}, BorderLayout.CENTER)
}
}
}
}
private fun appendBuildHintText(project: Project, text: StatusText) {
val shortcut = ActionManager.getInstance().getKeyboardShortcut("CompileDirty")
text.appendLine(DevkitComposeBundle.message("compose.preview.build"), SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES) { _ ->
val compileAction = ActionManager.getInstance().getAction("CompileDirty")!!
val dataContext = SimpleDataContext.builder().add(PROJECT, project).build()
ActionUtil.performAction(compileAction, AnActionEvent.createEvent(dataContext, Presentation(), "", ActionUiKind.NONE, null))
}
val shortcutText = shortcut?.let { " (${KeymapUtil.getShortcutText(shortcut)})" } ?: ""
text.appendText(shortcutText)
}
private val PROVIDER_KEY = Key.create<ContentProvider>("ComposePreviewToolWindowFactory.ContentProvider")