diff --git a/platform/jewel/ide-laf-bridge/ide-laf-bridge-tests/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProviderTest.kt b/platform/jewel/ide-laf-bridge/ide-laf-bridge-tests/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProviderTest.kt new file mode 100644 index 000000000000..f4777cd78d24 --- /dev/null +++ b/platform/jewel/ide-laf-bridge/ide-laf-bridge-tests/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProviderTest.kt @@ -0,0 +1,32 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.bridge.actionSystem + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.progress.ProcessCanceledException +import org.jetbrains.jewel.bridge.ComposeSemanticsTreeUtils +import org.jetbrains.jewel.foundation.InternalJewelApi +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +@OptIn(InternalJewelApi::class) +public class ComposePasteProviderTest { + @Test + public fun `paste provider updates on EDT`() { + assertSame(ActionUpdateThread.EDT, ComposePasteProvider().actionUpdateThread) + } + + @Test + public fun `focused component lookup returns null when semantics traversal fails`() { + assertNull( + ComposeSemanticsTreeUtils.findFocusedComponent { + throw NullPointerException("transient Compose semantics tree mutation") + } + ) + } + + @Test(expected = ProcessCanceledException::class) + public fun `focused component lookup does not swallow cancellation`() { + ComposeSemanticsTreeUtils.findFocusedComponent { throw ProcessCanceledException() } + } +} diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt index 9dc847dfe0ff..48a76a3387a3 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getAllSemanticsNodes import androidx.compose.ui.semantics.getOrNull @@ -14,13 +15,29 @@ import org.jetbrains.jewel.foundation.InternalJewelApi @Internal @InternalJewelApi public object ComposeSemanticsTreeUtils { - public fun ComposePanel.findFocusedComponent(): SemanticsNode? { - return semanticsOwners.firstNotNullOfOrNull { o -> - o.getAllSemanticsNodes(mergingEnabled = true).firstOrNull { - it.config.getOrNull(SemanticsProperties.Focused) == true + /** + * Returns the currently focused semantics node from this [ComposePanel], or `null` if there is none. + * + * This lookup fail-closes when Compose reports a transiently inconsistent semantics tree during mutation. + */ + public fun ComposePanel.findFocusedComponent(): SemanticsNode? = findFocusedComponent { semanticsOwners } + + /** + * Returns the currently focused semantics node from [semanticsOwners]. + * + * The Compose semantics tree can be transiently inconsistent while it is being mutated, so this lookup fail-closes + * on [NullPointerException] and lets callers behave as if no focused Compose component is available. + */ + internal fun findFocusedComponent(semanticsOwners: () -> Iterable): SemanticsNode? = + try { + semanticsOwners().firstNotNullOfOrNull { o -> + o.getAllSemanticsNodes(mergingEnabled = true).firstOrNull { + it.config.getOrNull(SemanticsProperties.Focused) == true + } } + } catch (_: NullPointerException) { + null } - } public fun SemanticsNode.isEditableTextField(): Boolean { // Check if the node has editable text or supports setting text diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt index 15730180f139..890ab2c6b042 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt @@ -25,7 +25,8 @@ public class ComposePasteProvider : PasteProvider { override fun isPasteEnabled(dataContext: DataContext): Boolean = isPastePossible(dataContext) - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + // This needs to run on the EDT, or we risk synchronization issues like JEWEL-1359 + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT private fun getPasteAction(dataContext: DataContext): AccessibilityAction<() -> Boolean>? { val contextComponent = dataContext.getData(PlatformCoreDataKeys.CONTEXT_COMPONENT)