Skip to content

Commit b7eeeae

Browse files
authored
Merge pull request #470 from unit-mesh/feat/mpp-idea-compose-ui-v2
feat(mpp-idea): add Compose UI module for IntelliJ IDEA 2025.2+
2 parents d0b3ae0 + 89cb64a commit b7eeeae

File tree

12 files changed

+574
-0
lines changed

12 files changed

+574
-0
lines changed

mpp-idea/build.gradle.kts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
2+
3+
plugins {
4+
id("java")
5+
kotlin("jvm") version "2.2.0"
6+
id("org.jetbrains.intellij.platform") version "2.10.2"
7+
kotlin("plugin.compose") version "2.2.0"
8+
kotlin("plugin.serialization") version "2.2.0"
9+
}
10+
11+
group = "cc.unitmesh.devins"
12+
version = project.findProperty("mppVersion") as String? ?: "0.3.2"
13+
14+
kotlin {
15+
jvmToolchain(21)
16+
17+
compilerOptions {
18+
freeCompilerArgs.addAll(
19+
listOf(
20+
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
21+
)
22+
)
23+
}
24+
}
25+
26+
repositories {
27+
mavenCentral()
28+
29+
intellijPlatform {
30+
defaultRepositories()
31+
}
32+
google()
33+
}
34+
35+
dependencies {
36+
// Kotlinx serialization for JSON
37+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
38+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
39+
40+
testImplementation(kotlin("test"))
41+
42+
intellijPlatform {
43+
// Target IntelliJ IDEA 2025.2+ for Compose support
44+
create("IC", "2025.2.1")
45+
46+
bundledPlugins("com.intellij.java")
47+
48+
// Compose support dependencies (bundled in IDEA 252+)
49+
bundledModules(
50+
"intellij.libraries.skiko",
51+
"intellij.libraries.compose.foundation.desktop",
52+
"intellij.platform.jewel.foundation",
53+
"intellij.platform.jewel.ui",
54+
"intellij.platform.jewel.ideLafBridge",
55+
"intellij.platform.compose"
56+
)
57+
58+
testFramework(TestFrameworkType.Platform)
59+
}
60+
}
61+
62+
intellijPlatform {
63+
pluginConfiguration {
64+
name = "AutoDev Compose UI"
65+
version = project.findProperty("mppVersion") as String? ?: "0.3.2"
66+
67+
ideaVersion {
68+
sinceBuild = "252"
69+
}
70+
}
71+
72+
buildSearchableOptions = false
73+
instrumentCode = false
74+
}
75+
76+
tasks {
77+
test {
78+
useJUnitPlatform()
79+
}
80+
}

mpp-idea/settings.gradle.kts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
rootProject.name = "mpp-idea"
2+
3+
pluginManagement {
4+
repositories {
5+
google()
6+
mavenCentral()
7+
gradlePluginPortal()
8+
}
9+
}
10+
11+
dependencyResolutionManagement {
12+
repositories {
13+
google()
14+
mavenCentral()
15+
}
16+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package cc.unitmesh.devins.idea
2+
3+
import com.intellij.openapi.util.IconLoader
4+
import javax.swing.Icon
5+
6+
/**
7+
* Icon provider for AutoDev Compose module.
8+
* Icons are loaded from resources for use in toolbars, tool windows, etc.
9+
*/
10+
object AutoDevIcons {
11+
/**
12+
* Tool window icon (13x13 for tool window, 16x16 for actions)
13+
*/
14+
@JvmField
15+
val ToolWindow: Icon = IconLoader.getIcon("/icons/autodev-toolwindow.svg", AutoDevIcons::class.java)
16+
17+
/**
18+
* Main AutoDev icon
19+
*/
20+
@JvmField
21+
val AutoDev: Icon = IconLoader.getIcon("/icons/autodev.svg", AutoDevIcons::class.java)
22+
}
23+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cc.unitmesh.devins.idea.services
2+
3+
import com.intellij.openapi.components.Service
4+
import com.intellij.openapi.components.Service.Level
5+
import com.intellij.platform.util.coroutines.childScope
6+
import kotlinx.coroutines.CoroutineScope
7+
8+
/**
9+
* A service-level class that provides and manages coroutine scopes for a given project.
10+
*
11+
* @constructor Initializes the [CoroutineScopeHolder] with a project-wide coroutine scope.
12+
* @param projectWideCoroutineScope A [CoroutineScope] defining the lifecycle of project-wide coroutines.
13+
*/
14+
@Service(Level.PROJECT)
15+
class CoroutineScopeHolder(private val projectWideCoroutineScope: CoroutineScope) {
16+
/**
17+
* Creates a new coroutine scope as a child of the project-wide coroutine scope with the specified name.
18+
*
19+
* @param name The name for the newly created coroutine scope.
20+
* @return a scope with a Job which parent is the Job of projectWideCoroutineScope scope.
21+
*
22+
* The returned scope can be completed only by cancellation.
23+
* projectWideCoroutineScope scope will cancel the returned scope when canceled.
24+
* If the child scope has a narrower lifecycle than projectWideCoroutineScope scope,
25+
* then it should be canceled explicitly when not needed,
26+
* otherwise, it will continue to live in the Job hierarchy until termination of the CoroutineScopeHolder service.
27+
*/
28+
@Suppress("UnstableApiUsage")
29+
fun createScope(name: String): CoroutineScope = projectWideCoroutineScope.childScope(name)
30+
}
31+
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package cc.unitmesh.devins.idea.toolwindow
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.*
5+
import androidx.compose.foundation.lazy.LazyColumn
6+
import androidx.compose.foundation.lazy.items
7+
import androidx.compose.foundation.lazy.rememberLazyListState
8+
import androidx.compose.foundation.text.input.rememberTextFieldState
9+
import androidx.compose.runtime.*
10+
import androidx.compose.ui.Alignment
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.input.key.*
13+
import androidx.compose.ui.text.font.FontWeight
14+
import androidx.compose.ui.unit.dp
15+
import androidx.compose.ui.unit.sp
16+
import kotlinx.coroutines.flow.distinctUntilChanged
17+
import org.jetbrains.jewel.foundation.theme.JewelTheme
18+
import org.jetbrains.jewel.ui.Orientation
19+
import org.jetbrains.jewel.ui.component.*
20+
import org.jetbrains.jewel.ui.theme.defaultBannerStyle
21+
22+
/**
23+
* Main Compose application for AutoDev Chat.
24+
*
25+
* Uses Jewel theme for native IntelliJ IDEA integration.
26+
*/
27+
@Composable
28+
fun AutoDevChatApp(viewModel: AutoDevChatViewModel) {
29+
val messages by viewModel.chatMessages.collectAsState()
30+
val inputState by viewModel.inputState.collectAsState()
31+
val listState = rememberLazyListState()
32+
33+
// Auto-scroll to bottom when new messages arrive
34+
LaunchedEffect(messages.size) {
35+
if (messages.isNotEmpty()) {
36+
listState.animateScrollToItem(messages.lastIndex)
37+
}
38+
}
39+
40+
Column(
41+
modifier = Modifier
42+
.fillMaxSize()
43+
.background(JewelTheme.globalColors.panelBackground)
44+
) {
45+
// Header
46+
ChatHeader(
47+
onNewConversation = { viewModel.onNewConversation() }
48+
)
49+
50+
Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp))
51+
52+
// Message list
53+
Box(
54+
modifier = Modifier
55+
.fillMaxWidth()
56+
.weight(1f)
57+
) {
58+
if (messages.isEmpty()) {
59+
EmptyStateMessage()
60+
} else {
61+
LazyColumn(
62+
state = listState,
63+
modifier = Modifier.fillMaxSize(),
64+
contentPadding = PaddingValues(8.dp),
65+
verticalArrangement = Arrangement.spacedBy(4.dp)
66+
) {
67+
items(messages, key = { it.id }) { message ->
68+
MessageBubble(message)
69+
}
70+
}
71+
}
72+
}
73+
74+
Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp))
75+
76+
// Input area
77+
ChatInput(
78+
inputState = inputState,
79+
onInputChanged = { viewModel.onInputChanged(it) },
80+
onSend = { viewModel.onSendMessage() },
81+
onAbort = { viewModel.onAbortMessage() }
82+
)
83+
}
84+
}
85+
86+
@Composable
87+
private fun ChatHeader(
88+
onNewConversation: () -> Unit
89+
) {
90+
Row(
91+
modifier = Modifier
92+
.fillMaxWidth()
93+
.padding(8.dp),
94+
horizontalArrangement = Arrangement.SpaceBetween,
95+
verticalAlignment = Alignment.CenterVertically
96+
) {
97+
Text(
98+
text = "AutoDev Chat",
99+
style = JewelTheme.defaultTextStyle.copy(
100+
fontWeight = FontWeight.Bold,
101+
fontSize = 14.sp
102+
)
103+
)
104+
105+
IconButton(onClick = onNewConversation) {
106+
Text("+", style = JewelTheme.defaultTextStyle)
107+
}
108+
}
109+
}
110+
111+
@Composable
112+
private fun EmptyStateMessage() {
113+
Box(
114+
modifier = Modifier.fillMaxSize(),
115+
contentAlignment = Alignment.Center
116+
) {
117+
Text(
118+
text = "Start a conversation with your AI Assistant!",
119+
style = JewelTheme.defaultTextStyle.copy(
120+
fontSize = 16.sp,
121+
color = JewelTheme.globalColors.text.info
122+
)
123+
)
124+
}
125+
}
126+
127+
@Composable
128+
private fun MessageBubble(message: ChatMessage) {
129+
Row(
130+
modifier = Modifier.fillMaxWidth(),
131+
horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start
132+
) {
133+
Box(
134+
modifier = Modifier
135+
.widthIn(max = 300.dp)
136+
.background(
137+
if (message.isUser)
138+
JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.75f)
139+
else
140+
JewelTheme.globalColors.panelBackground
141+
)
142+
.padding(8.dp)
143+
) {
144+
Text(
145+
text = message.content,
146+
style = JewelTheme.defaultTextStyle
147+
)
148+
}
149+
}
150+
}
151+
152+
@Composable
153+
private fun ChatInput(
154+
inputState: MessageInputState,
155+
onInputChanged: (String) -> Unit,
156+
onSend: () -> Unit,
157+
onAbort: () -> Unit
158+
) {
159+
val textFieldState = rememberTextFieldState()
160+
val isSending = inputState is MessageInputState.Sending
161+
162+
LaunchedEffect(Unit) {
163+
snapshotFlow { textFieldState.text.toString() }
164+
.distinctUntilChanged()
165+
.collect { onInputChanged(it) }
166+
}
167+
168+
Row(
169+
modifier = Modifier
170+
.fillMaxWidth()
171+
.padding(8.dp),
172+
horizontalArrangement = Arrangement.spacedBy(8.dp),
173+
verticalAlignment = Alignment.CenterVertically
174+
) {
175+
TextField(
176+
state = textFieldState,
177+
placeholder = { Text("Type your message...") },
178+
modifier = Modifier
179+
.weight(1f)
180+
.onPreviewKeyEvent { keyEvent ->
181+
if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown && !isSending) {
182+
onSend()
183+
textFieldState.edit { replace(0, length, "") }
184+
true
185+
} else {
186+
false
187+
}
188+
},
189+
enabled = !isSending
190+
)
191+
192+
if (isSending) {
193+
DefaultButton(onClick = onAbort) {
194+
Text("Stop")
195+
}
196+
} else {
197+
DefaultButton(
198+
onClick = {
199+
onSend()
200+
textFieldState.edit { replace(0, length, "") }
201+
},
202+
enabled = inputState is MessageInputState.Enabled
203+
) {
204+
Text("Send")
205+
}
206+
}
207+
}
208+
}
209+

0 commit comments

Comments
 (0)