Skip to content

Commit 06a3225

Browse files
committed
Fix IDEA coroutine scope lifecycle
1 parent 94a52c4 commit 06a3225

15 files changed

Lines changed: 304 additions & 134 deletions

File tree

mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/util/AutoDevCoroutineScope.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ class AutoDevCoroutineScope : Disposable {
6868
replaceWith = ReplaceWith("AutoDevCoroutineScope.workScope(project)", imports = ["cc.unitmesh.devti.util.AutoDevCoroutineScope"])
6969
)
7070
fun workerThread(): CoroutineScope = CoroutineScope(SupervisorJob() + workerThread)
71-
fun workerScope(project: Project): CoroutineScope = project.service<AutoDevCoroutineScope>().coroutineScope
71+
fun workerScope(project: Project): CoroutineScope = selectWorkerScope(project.service())
7272
}
7373
}
74+
75+
internal fun selectWorkerScope(service: AutoDevCoroutineScope): CoroutineScope = service.workerScope
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package cc.unitmesh.devti.util
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertNotSame
5+
import kotlin.test.assertSame
6+
7+
class AutoDevCoroutineScopeTest {
8+
9+
@Test
10+
fun `project worker scope selector returns worker scope`() {
11+
val service = AutoDevCoroutineScope()
12+
13+
try {
14+
val selected = selectWorkerScope(service)
15+
16+
assertSame(service.workerScope, selected)
17+
assertNotSame(service.coroutineScope, selected)
18+
} finally {
19+
service.dispose()
20+
}
21+
}
22+
}

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compose/IdeaComposeEffects.kt

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.intellij.openapi.project.Project
1818
import kotlinx.coroutines.CoroutineScope
1919
import kotlinx.coroutines.Dispatchers
2020
import kotlinx.coroutines.SupervisorJob
21+
import kotlinx.coroutines.cancel
2122
import kotlinx.coroutines.launch
2223
import org.jetbrains.jewel.foundation.theme.JewelTheme
2324
import javax.swing.Timer
@@ -39,15 +40,29 @@ private val appScope: CoroutineScope by lazy {
3940
CoroutineScope(SupervisorJob() + Dispatchers.EDT)
4041
}
4142

42-
/**
43-
* Gets a coroutine scope from the project's CoroutineScopeHolder service,
44-
* or falls back to an application-level scope if no project is provided.
45-
*/
46-
private fun getScope(project: Project?): CoroutineScope {
43+
internal class IdeaComposeScopeHandle(
44+
val scope: CoroutineScope,
45+
private val ownsScope: Boolean
46+
) {
47+
fun dispose() {
48+
if (ownsScope) {
49+
scope.cancel()
50+
}
51+
}
52+
}
53+
54+
internal fun createIdeaComposeScopeHandle(
55+
project: Project?,
56+
name: String = "IdeaComposeEffect",
57+
projectScopeFactory: (Project, String) -> CoroutineScope = { scopeProject, scopeName ->
58+
scopeProject.service<CoroutineScopeHolder>().createScope(scopeName)
59+
},
60+
fallbackScope: CoroutineScope = appScope
61+
): IdeaComposeScopeHandle {
4762
return if (project != null && !project.isDisposed) {
48-
project.service<CoroutineScopeHolder>().createScope("IdeaComposeEffect")
63+
IdeaComposeScopeHandle(projectScopeFactory(project, name), ownsScope = true)
4964
} else {
50-
appScope
65+
IdeaComposeScopeHandle(fallbackScope, ownsScope = false)
5166
}
5267
}
5368

@@ -57,9 +72,15 @@ private fun getScope(project: Project?): CoroutineScope {
5772
*/
5873
@Composable
5974
fun rememberIdeaCoroutineScope(project: Project? = null): CoroutineScope {
60-
return remember(project) {
61-
getScope(project)
75+
val handle = remember(project) {
76+
createIdeaComposeScopeHandle(project, name = "IdeaRememberedCoroutineScope")
77+
}
78+
79+
DisposableEffect(handle) {
80+
onDispose { handle.dispose() }
6281
}
82+
83+
return handle.scope
6384
}
6485

6586
/**
@@ -76,11 +97,13 @@ fun IdeaLaunchedEffect(
7697
project: Project? = null,
7798
block: suspend CoroutineScope.() -> Unit
7899
) {
79-
val scope = getScope(project)
80-
81-
DisposableEffect(key1) {
82-
val job = scope.launch { block() }
83-
onDispose { job.cancel() }
100+
DisposableEffect(key1, project) {
101+
val handle = createIdeaComposeScopeHandle(project)
102+
val job = handle.scope.launch { block() }
103+
onDispose {
104+
job.cancel()
105+
handle.dispose()
106+
}
84107
}
85108
}
86109

@@ -94,11 +117,13 @@ fun IdeaLaunchedEffect(
94117
project: Project? = null,
95118
block: suspend CoroutineScope.() -> Unit
96119
) {
97-
val scope = getScope(project)
98-
99-
DisposableEffect(key1, key2) {
100-
val job = scope.launch { block() }
101-
onDispose { job.cancel() }
120+
DisposableEffect(key1, key2, project) {
121+
val handle = createIdeaComposeScopeHandle(project)
122+
val job = handle.scope.launch { block() }
123+
onDispose {
124+
job.cancel()
125+
handle.dispose()
126+
}
102127
}
103128
}
104129

@@ -113,11 +138,13 @@ fun IdeaLaunchedEffect(
113138
project: Project? = null,
114139
block: suspend CoroutineScope.() -> Unit
115140
) {
116-
val scope = getScope(project)
117-
118-
DisposableEffect(key1, key2, key3) {
119-
val job = scope.launch { block() }
120-
onDispose { job.cancel() }
141+
DisposableEffect(key1, key2, key3, project) {
142+
val handle = createIdeaComposeScopeHandle(project)
143+
val job = handle.scope.launch { block() }
144+
onDispose {
145+
job.cancel()
146+
handle.dispose()
147+
}
121148
}
122149
}
123150

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/omnibar/IdeaOmnibarDialog.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight
2323
import androidx.compose.ui.text.style.TextOverflow
2424
import androidx.compose.ui.unit.dp
2525
import androidx.compose.ui.unit.sp
26+
import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect
27+
import cc.unitmesh.devins.idea.compose.rememberIdeaCoroutineScope
2628
import cc.unitmesh.devins.idea.omnibar.model.OmnibarActionResult
2729
import cc.unitmesh.devins.idea.omnibar.model.OmnibarItem
2830
import cc.unitmesh.devins.idea.omnibar.model.OmnibarItemType
@@ -99,10 +101,10 @@ fun IdeaOmnibarContent(
99101
val dataProvider = remember { IdeaOmnibarDataProvider.getInstance(project) }
100102
val focusRequester = remember { FocusRequester() }
101103
val listState = rememberLazyListState()
102-
val scope = rememberCoroutineScope()
104+
val scope = rememberIdeaCoroutineScope(project)
103105

104106
// Load items
105-
LaunchedEffect(Unit) {
107+
IdeaLaunchedEffect(Unit, project = project) {
106108
isLoading = true
107109
try {
108110
val items = dataProvider.getItems()
@@ -117,14 +119,14 @@ fun IdeaOmnibarContent(
117119
}
118120

119121
// Update filtered items on query change
120-
LaunchedEffect(searchQuery, allItems) {
122+
IdeaLaunchedEffect(searchQuery, allItems, project = project) {
121123
delay(50)
122124
filteredItems = OmnibarSearchEngine.search(allItems, searchQuery)
123125
selectedIndex = 0
124126
}
125127

126128
// Auto-scroll
127-
LaunchedEffect(selectedIndex) {
129+
IdeaLaunchedEffect(selectedIndex, filteredItems, project = project) {
128130
if (filteredItems.isNotEmpty() && selectedIndex in filteredItems.indices) {
129131
listState.animateScrollToItem(selectedIndex)
130132
}
@@ -318,7 +320,7 @@ private fun IdeaOmnibarResultItem(
318320
val interactionSource = remember { MutableInteractionSource() }
319321
val isHovered by interactionSource.collectIsHoveredAsState()
320322

321-
LaunchedEffect(isHovered) {
323+
SideEffect {
322324
if (isHovered) onHover()
323325
}
324326

@@ -385,4 +387,3 @@ private fun IdeaOmnibarResultItem(
385387
}
386388
}
387389
}
388-

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/nano/IdeaNanoRenderer.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
99
import androidx.compose.ui.draw.clip
1010
import androidx.compose.ui.graphics.Color
1111
import androidx.compose.ui.unit.dp
12+
import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect
1213
import cc.unitmesh.xuiper.ir.NanoIR
1314
import cc.unitmesh.xuiper.dsl.NanoDSL
1415
import cc.unitmesh.xuiper.render.stateful.NanoNodeRegistry
@@ -107,7 +108,12 @@ object IdeaNanoRenderer {
107108
// Subscribe to declared state keys for recomposition
108109
val observedKeys = remember(session) { session.observedKeys }
109110
observedKeys.forEach { key ->
110-
session.flow(key).collectAsState().value
111+
val flow = remember(session, key) { session.flow(key) }
112+
var observedValue by remember(session, key) { mutableStateOf(flow.value) }
113+
IdeaLaunchedEffect(session, key) {
114+
flow.collect { observedValue = it }
115+
}
116+
observedValue
111117
}
112118

113119
// Create dispatcher from registry
@@ -146,4 +152,3 @@ object IdeaNanoRenderer {
146152
}
147153
}
148154
}
149-

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/nano/components/JewelDateComponents.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.runtime.*
77
import androidx.compose.ui.Alignment
88
import androidx.compose.ui.Modifier
99
import androidx.compose.ui.unit.dp
10+
import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect
1011
import cc.unitmesh.xuiper.action.NanoActionFactory
1112
import cc.unitmesh.xuiper.ir.stringProp
1213
import cc.unitmesh.xuiper.props.NanoBindingResolver
@@ -34,15 +35,15 @@ object JewelDateComponents {
3435

3536
val textFieldState = rememberTextFieldState(initialValue)
3637

37-
LaunchedEffect(textFieldState.text) {
38+
IdeaLaunchedEffect(textFieldState.text) {
3839
val newValue = textFieldState.text.toString()
3940
if (newValue != initialValue) {
4041
statePath?.let { ctx.onAction(NanoActionFactory.set(it, newValue)) }
4142
onChange?.let { ctx.onAction(it) }
4243
}
4344
}
4445

45-
LaunchedEffect(initialValue) {
46+
IdeaLaunchedEffect(initialValue) {
4647
if (textFieldState.text.toString() != initialValue) {
4748
textFieldState.setTextAndPlaceCursorAtEnd(initialValue)
4849
}
@@ -78,29 +79,29 @@ object JewelDateComponents {
7879
val startTextFieldState = rememberTextFieldState(startInitialValue)
7980
val endTextFieldState = rememberTextFieldState(endInitialValue)
8081

81-
LaunchedEffect(startTextFieldState.text) {
82+
IdeaLaunchedEffect(startTextFieldState.text) {
8283
val newValue = startTextFieldState.text.toString()
8384
if (newValue != startInitialValue) {
8485
startPath?.let { ctx.onAction(NanoActionFactory.set(it, newValue)) }
8586
onChange?.let { ctx.onAction(it) }
8687
}
8788
}
8889

89-
LaunchedEffect(endTextFieldState.text) {
90+
IdeaLaunchedEffect(endTextFieldState.text) {
9091
val newValue = endTextFieldState.text.toString()
9192
if (newValue != endInitialValue) {
9293
endPath?.let { ctx.onAction(NanoActionFactory.set(it, newValue)) }
9394
onChange?.let { ctx.onAction(it) }
9495
}
9596
}
9697

97-
LaunchedEffect(startInitialValue) {
98+
IdeaLaunchedEffect(startInitialValue) {
9899
if (startTextFieldState.text.toString() != startInitialValue) {
99100
startTextFieldState.setTextAndPlaceCursorAtEnd(startInitialValue)
100101
}
101102
}
102103

103-
LaunchedEffect(endInitialValue) {
104+
IdeaLaunchedEffect(endInitialValue) {
104105
if (endTextFieldState.text.toString() != endInitialValue) {
105106
endTextFieldState.setTextAndPlaceCursorAtEnd(endInitialValue)
106107
}

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/nano/components/JewelInputComponents.kt

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
88
import androidx.compose.ui.Alignment
99
import androidx.compose.ui.Modifier
1010
import androidx.compose.ui.unit.dp
11+
import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect
1112
import cc.unitmesh.xuiper.action.NanoActionFactory
1213
import cc.unitmesh.xuiper.eval.evaluator.NanoExpressionEvaluator
1314
import cc.unitmesh.xuiper.ir.booleanProp
@@ -45,7 +46,7 @@ object JewelInputComponents {
4546
val textFieldState = rememberTextFieldState(initialValue)
4647

4748
// Sync state changes back to nano state
48-
LaunchedEffect(textFieldState.text) {
49+
IdeaLaunchedEffect(textFieldState.text) {
4950
val newValue = textFieldState.text.toString()
5051
if (newValue != initialValue) {
5152
statePath?.let { ctx.onAction(NanoActionFactory.set(it, newValue)) }
@@ -54,7 +55,7 @@ object JewelInputComponents {
5455
}
5556

5657
// Sync external state changes to text field
57-
LaunchedEffect(initialValue) {
58+
IdeaLaunchedEffect(initialValue) {
5859
if (textFieldState.text.toString() != initialValue) {
5960
textFieldState.setTextAndPlaceCursorAtEnd(initialValue)
6061
}
@@ -79,15 +80,15 @@ object JewelInputComponents {
7980

8081
val textFieldState = rememberTextFieldState(initialValue)
8182

82-
LaunchedEffect(textFieldState.text) {
83+
IdeaLaunchedEffect(textFieldState.text) {
8384
val newValue = textFieldState.text.toString()
8485
if (newValue != initialValue) {
8586
statePath?.let { ctx.onAction(NanoActionFactory.set(it, newValue)) }
8687
onChange?.let { ctx.onAction(it) }
8788
}
8889
}
8990

90-
LaunchedEffect(initialValue) {
91+
IdeaLaunchedEffect(initialValue) {
9192
if (textFieldState.text.toString() != initialValue) {
9293
textFieldState.setTextAndPlaceCursorAtEnd(initialValue)
9394
}
@@ -144,15 +145,15 @@ object JewelInputComponents {
144145

145146
val textFieldState = rememberTextFieldState(initialValue)
146147

147-
LaunchedEffect(textFieldState.text) {
148+
IdeaLaunchedEffect(textFieldState.text) {
148149
val newValue = textFieldState.text.toString()
149150
if (newValue.matches(Regex("-?\\d*\\.?\\d*")) && newValue != initialValue) {
150151
statePath?.let { ctx.onAction(NanoActionFactory.set(it, newValue)) }
151152
onChange?.let { ctx.onAction(it) }
152153
}
153154
}
154155

155-
LaunchedEffect(initialValue) {
156+
IdeaLaunchedEffect(initialValue) {
156157
if (textFieldState.text.toString() != initialValue) {
157158
textFieldState.setTextAndPlaceCursorAtEnd(initialValue)
158159
}
@@ -177,15 +178,15 @@ object JewelInputComponents {
177178

178179
val textFieldState = rememberTextFieldState(initialValue)
179180

180-
LaunchedEffect(textFieldState.text) {
181+
IdeaLaunchedEffect(textFieldState.text) {
181182
val newValue = textFieldState.text.toString()
182183
if (newValue != initialValue) {
183184
statePath?.let { ctx.onAction(NanoActionFactory.set(it, newValue)) }
184185
onChange?.let { ctx.onAction(it) }
185186
}
186187
}
187188

188-
LaunchedEffect(initialValue) {
189+
IdeaLaunchedEffect(initialValue) {
189190
if (textFieldState.text.toString() != initialValue) {
190191
textFieldState.setTextAndPlaceCursorAtEnd(initialValue)
191192
}

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaNanoDSLBlockRenderer.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
1111
import androidx.compose.ui.draw.clip
1212
import androidx.compose.ui.graphics.Color
1313
import androidx.compose.ui.unit.dp
14+
import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect
1415
import cc.unitmesh.agent.parser.NanoDSLValidator
1516
import cc.unitmesh.agent.parser.NanoDSLParseResult
1617
import org.jetbrains.jewel.foundation.theme.JewelTheme
@@ -41,7 +42,7 @@ fun IdeaNanoDSLBlockRenderer(
4142
var isValid by remember { mutableStateOf(false) }
4243

4344
// Validate NanoDSL when code changes
44-
LaunchedEffect(nanodslCode, isComplete) {
45+
IdeaLaunchedEffect(nanodslCode, isComplete) {
4546
if (isComplete && nanodslCode.isNotBlank()) {
4647
try {
4748
val validator = NanoDSLValidator()
@@ -192,4 +193,3 @@ fun IdeaNanoDSLBlockRenderer(
192193
}
193194
}
194195
}
195-

0 commit comments

Comments
 (0)