From 49fd96e75656e0b8a8b7eeb10a2072bdb94956cd Mon Sep 17 00:00:00 2001 From: Yuriy Artamonov Date: Fri, 26 Sep 2025 21:48:22 +0200 Subject: [PATCH] [compose] IJPL-208258 Refresh indication for Compose UI Preview GitOrigin-RevId: 87e929b554642a79ca27a39303743b99f300fb91 --- .../resources/intellij.devkit.compose.xml | 15 ++- .../messages/DevkitComposeBundle.properties | 11 +- .../src/preview/ComposePreviewBusyPanel.kt | 112 ++++++++++++++++++ ...ker.kt => ComposePreviewChangesTracker.kt} | 84 +++++++++++-- .../preview/ComposePreviewRefreshAction.kt | 12 +- .../ComposePreviewRefreshAutoAction.kt | 21 +++- .../src/preview/ComposePreviewSearcher.kt | 13 ++ .../ComposePreviewToolWindowFactory.kt | 88 ++++++-------- 8 files changed, 283 insertions(+), 73 deletions(-) create mode 100644 plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewBusyPanel.kt rename plugins/devkit/intellij.devkit.compose/src/preview/{EditorSelectionAndTextChangesTracker.kt => ComposePreviewChangesTracker.kt} (62%) create mode 100644 plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewSearcher.kt diff --git a/plugins/devkit/intellij.devkit.compose/resources/intellij.devkit.compose.xml b/plugins/devkit/intellij.devkit.compose/resources/intellij.devkit.compose.xml index d53f4ee59903..1732fd34a721 100644 --- a/plugins/devkit/intellij.devkit.compose/resources/intellij.devkit.compose.xml +++ b/plugins/devkit/intellij.devkit.compose/resources/intellij.devkit.compose.xml @@ -34,9 +34,10 @@ - + @@ -62,10 +63,14 @@ + icon="AllIcons.General.Refresh"> + + + icon="AllIcons.General.RefreshAuto"> + + diff --git a/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties b/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties index 474bc9f7cb82..24074c2f9638 100644 --- a/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties +++ b/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties @@ -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 +compose.preview.refresh=\u2014 +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 \ No newline at end of file diff --git a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewBusyPanel.kt b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewBusyPanel.kt new file mode 100644 index 000000000000..11b9acaa63bc --- /dev/null +++ b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewBusyPanel.kt @@ -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().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) + } +} \ No newline at end of file diff --git a/plugins/devkit/intellij.devkit.compose/src/preview/EditorSelectionAndTextChangesTracker.kt b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewChangesTracker.kt similarity index 62% rename from plugins/devkit/intellij.devkit.compose/src/preview/EditorSelectionAndTextChangesTracker.kt rename to plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewChangesTracker.kt index c7f7dea2e585..4abd206340b9 100644 --- a/plugins/devkit/intellij.devkit.compose/src/preview/EditorSelectionAndTextChangesTracker.kt +++ b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewChangesTracker.kt @@ -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 = + 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> { + fun refresh() { + refreshCallback?.invoke() + } + + private fun observeManualRefresh(): Flow { + 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 { 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) } diff --git a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAction.kt b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAction.kt index 848a73dad81d..d99c5cf36ee1 100644 --- a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAction.kt +++ b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAction.kt @@ -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().refresh() } } diff --git a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAutoAction.kt b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAutoAction.kt index 610d3b97611e..c78df2e491da 100644 --- a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAutoAction.kt +++ b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewRefreshAutoAction.kt @@ -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().isAutoRefresh() } override fun setSelected(e: AnActionEvent, state: Boolean) { - this.refresh = state + val p = e.project ?: return + + val tracker = p.service() + tracker.setAutoRefresh(state) + if (state) { + tracker.refresh() + } } } diff --git a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewSearcher.kt b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewSearcher.kt new file mode 100644 index 000000000000..ad3f5e57eccd --- /dev/null +++ b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewSearcher.kt @@ -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) + } +} diff --git a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewToolWindowFactory.kt b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewToolWindowFactory.kt index bce6c6732ca0..65072704d4a3 100644 --- a/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewToolWindowFactory.kt +++ b/plugins/devkit/intellij.devkit.compose/src/preview/ComposePreviewToolWindowFactory.kt @@ -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().startTracking(project, toolWindowContent) { virtualFile -> - try { - val provider = compileCode(virtualFile, project) ?: return@startTracking + project.service().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("ComposePreviewToolWindowFactory.ContentProvider")