diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts new file mode 100644 index 0000000000..85eca3fa14 --- /dev/null +++ b/mpp-idea/build.gradle.kts @@ -0,0 +1,80 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +plugins { + id("java") + kotlin("jvm") version "2.2.0" + id("org.jetbrains.intellij.platform") version "2.10.2" + kotlin("plugin.compose") version "2.2.0" + kotlin("plugin.serialization") version "2.2.0" +} + +group = "cc.unitmesh.devins" +version = project.findProperty("mppVersion") as String? ?: "0.3.2" + +kotlin { + jvmToolchain(21) + + compilerOptions { + freeCompilerArgs.addAll( + listOf( + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi" + ) + ) + } +} + +repositories { + mavenCentral() + + intellijPlatform { + defaultRepositories() + } + google() +} + +dependencies { + // Kotlinx serialization for JSON + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") + + testImplementation(kotlin("test")) + + intellijPlatform { + // Target IntelliJ IDEA 2025.2+ for Compose support + create("IC", "2025.2.1") + + bundledPlugins("com.intellij.java") + + // Compose support dependencies (bundled in IDEA 252+) + bundledModules( + "intellij.libraries.skiko", + "intellij.libraries.compose.foundation.desktop", + "intellij.platform.jewel.foundation", + "intellij.platform.jewel.ui", + "intellij.platform.jewel.ideLafBridge", + "intellij.platform.compose" + ) + + testFramework(TestFrameworkType.Platform) + } +} + +intellijPlatform { + pluginConfiguration { + name = "AutoDev Compose UI" + version = project.findProperty("mppVersion") as String? ?: "0.3.2" + + ideaVersion { + sinceBuild = "252" + } + } + + buildSearchableOptions = false + instrumentCode = false +} + +tasks { + test { + useJUnitPlatform() + } +} diff --git a/mpp-idea/settings.gradle.kts b/mpp-idea/settings.gradle.kts new file mode 100644 index 0000000000..97ea357ad5 --- /dev/null +++ b/mpp-idea/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "mpp-idea" + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/AutoDevIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/AutoDevIcons.kt new file mode 100644 index 0000000000..e1c3ab9dea --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/AutoDevIcons.kt @@ -0,0 +1,23 @@ +package cc.unitmesh.devins.idea + +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +/** + * Icon provider for AutoDev Compose module. + * Icons are loaded from resources for use in toolbars, tool windows, etc. + */ +object AutoDevIcons { + /** + * Tool window icon (13x13 for tool window, 16x16 for actions) + */ + @JvmField + val ToolWindow: Icon = IconLoader.getIcon("/icons/autodev-toolwindow.svg", AutoDevIcons::class.java) + + /** + * Main AutoDev icon + */ + @JvmField + val AutoDev: Icon = IconLoader.getIcon("/icons/autodev.svg", AutoDevIcons::class.java) +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/CoroutineScopeHolder.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/CoroutineScopeHolder.kt new file mode 100644 index 0000000000..d733160230 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/CoroutineScopeHolder.kt @@ -0,0 +1,31 @@ +package cc.unitmesh.devins.idea.services + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.Service.Level +import com.intellij.platform.util.coroutines.childScope +import kotlinx.coroutines.CoroutineScope + +/** + * A service-level class that provides and manages coroutine scopes for a given project. + * + * @constructor Initializes the [CoroutineScopeHolder] with a project-wide coroutine scope. + * @param projectWideCoroutineScope A [CoroutineScope] defining the lifecycle of project-wide coroutines. + */ +@Service(Level.PROJECT) +class CoroutineScopeHolder(private val projectWideCoroutineScope: CoroutineScope) { + /** + * Creates a new coroutine scope as a child of the project-wide coroutine scope with the specified name. + * + * @param name The name for the newly created coroutine scope. + * @return a scope with a Job which parent is the Job of projectWideCoroutineScope scope. + * + * The returned scope can be completed only by cancellation. + * projectWideCoroutineScope scope will cancel the returned scope when canceled. + * If the child scope has a narrower lifecycle than projectWideCoroutineScope scope, + * then it should be canceled explicitly when not needed, + * otherwise, it will continue to live in the Job hierarchy until termination of the CoroutineScopeHolder service. + */ + @Suppress("UnstableApiUsage") + fun createScope(name: String): CoroutineScope = projectWideCoroutineScope.childScope(name) +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevChatApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevChatApp.kt new file mode 100644 index 0000000000..cf2521857b --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevChatApp.kt @@ -0,0 +1,209 @@ +package cc.unitmesh.devins.idea.toolwindow + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.theme.defaultBannerStyle + +/** + * Main Compose application for AutoDev Chat. + * + * Uses Jewel theme for native IntelliJ IDEA integration. + */ +@Composable +fun AutoDevChatApp(viewModel: AutoDevChatViewModel) { + val messages by viewModel.chatMessages.collectAsState() + val inputState by viewModel.inputState.collectAsState() + val listState = rememberLazyListState() + + // Auto-scroll to bottom when new messages arrive + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.lastIndex) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(JewelTheme.globalColors.panelBackground) + ) { + // Header + ChatHeader( + onNewConversation = { viewModel.onNewConversation() } + ) + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Message list + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + if (messages.isEmpty()) { + EmptyStateMessage() + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(messages, key = { it.id }) { message -> + MessageBubble(message) + } + } + } + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Input area + ChatInput( + inputState = inputState, + onInputChanged = { viewModel.onInputChanged(it) }, + onSend = { viewModel.onSendMessage() }, + onAbort = { viewModel.onAbortMessage() } + ) + } +} + +@Composable +private fun ChatHeader( + onNewConversation: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "AutoDev Chat", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + + IconButton(onClick = onNewConversation) { + Text("+", style = JewelTheme.defaultTextStyle) + } + } +} + +@Composable +private fun EmptyStateMessage() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Start a conversation with your AI Assistant!", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 16.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } +} + +@Composable +private fun MessageBubble(message: ChatMessage) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .background( + if (message.isUser) + JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.75f) + else + JewelTheme.globalColors.panelBackground + ) + .padding(8.dp) + ) { + Text( + text = message.content, + style = JewelTheme.defaultTextStyle + ) + } + } +} + +@Composable +private fun ChatInput( + inputState: MessageInputState, + onInputChanged: (String) -> Unit, + onSend: () -> Unit, + onAbort: () -> Unit +) { + val textFieldState = rememberTextFieldState() + val isSending = inputState is MessageInputState.Sending + + LaunchedEffect(Unit) { + snapshotFlow { textFieldState.text.toString() } + .distinctUntilChanged() + .collect { onInputChanged(it) } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + state = textFieldState, + placeholder = { Text("Type your message...") }, + modifier = Modifier + .weight(1f) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown && !isSending) { + onSend() + textFieldState.edit { replace(0, length, "") } + true + } else { + false + } + }, + enabled = !isSending + ) + + if (isSending) { + DefaultButton(onClick = onAbort) { + Text("Stop") + } + } else { + DefaultButton( + onClick = { + onSend() + textFieldState.edit { replace(0, length, "") } + }, + enabled = inputState is MessageInputState.Enabled + ) { + Text("Send") + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevChatViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevChatViewModel.kt new file mode 100644 index 0000000000..96cfc964e2 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevChatViewModel.kt @@ -0,0 +1,115 @@ +package cc.unitmesh.devins.idea.toolwindow + +import com.intellij.openapi.Disposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Represents the state of the message input. + */ +sealed class MessageInputState { + abstract val inputText: String + + data object Disabled : MessageInputState() { + override val inputText: String = "" + } + + data class Enabled(override val inputText: String) : MessageInputState() + + data class Sending(override val inputText: String) : MessageInputState() + + data class SendFailed(override val inputText: String, val error: Throwable) : MessageInputState() +} + +/** + * Represents a chat message. + */ +data class ChatMessage( + val id: String, + val content: String, + val isUser: Boolean, + val timestamp: Long = System.currentTimeMillis() +) + +/** + * ViewModel for the AutoDev Chat interface. + * + * Manages chat messages, user input state, and message sending. + */ +class AutoDevChatViewModel( + private val coroutineScope: CoroutineScope +) : Disposable { + + private val _chatMessages = MutableStateFlow>(emptyList()) + val chatMessages: StateFlow> = _chatMessages.asStateFlow() + + private val _inputState = MutableStateFlow(MessageInputState.Disabled) + val inputState: StateFlow = _inputState.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + /** + * Update the input text. + */ + fun onInputChanged(text: String) { + _inputState.value = when { + _inputState.value is MessageInputState.Sending -> MessageInputState.Sending(text) + text.isEmpty() -> MessageInputState.Disabled + else -> MessageInputState.Enabled(text) + } + } + + /** + * Send the current message. + */ + fun onSendMessage() { + val currentText = _inputState.value.inputText + if (currentText.isBlank()) return + + // Add user message + val userMessage = ChatMessage( + id = java.util.UUID.randomUUID().toString(), + content = currentText, + isUser = true + ) + _chatMessages.value = _chatMessages.value + userMessage + _inputState.value = MessageInputState.Sending("") + + // TODO: Integrate with mpp-core for AI responses + // For now, add a placeholder response + val aiResponse = ChatMessage( + id = java.util.UUID.randomUUID().toString(), + content = "This is a placeholder response. AI integration coming soon!", + isUser = false + ) + _chatMessages.value = _chatMessages.value + aiResponse + _inputState.value = MessageInputState.Disabled + } + + /** + * Abort the current message sending. + */ + fun onAbortMessage() { + _inputState.value = when (val text = _inputState.value.inputText) { + "" -> MessageInputState.Disabled + else -> MessageInputState.Enabled(text) + } + } + + /** + * Create a new conversation. + */ + fun onNewConversation() { + _chatMessages.value = emptyList() + _inputState.value = MessageInputState.Disabled + } + + override fun dispose() { + coroutineScope.cancel() + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevToolWindowFactory.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevToolWindowFactory.kt new file mode 100644 index 0000000000..3eb1c48ef6 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/AutoDevToolWindowFactory.kt @@ -0,0 +1,42 @@ +package cc.unitmesh.devins.idea.toolwindow + +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import cc.unitmesh.devins.idea.services.CoroutineScopeHolder +import org.jetbrains.jewel.bridge.addComposeTab + +/** + * Factory for creating the AutoDev Compose ToolWindow. + * + * This factory creates a Compose-based UI tool window that uses the Jewel theme + * for native IntelliJ IDEA integration (2025.2+). + */ +class AutoDevToolWindowFactory : ToolWindowFactory { + + init { + thisLogger().info("AutoDevToolWindowFactory initialized - Compose UI for IntelliJ IDEA 252+") + } + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + createChatPanel(project, toolWindow) + } + + override fun shouldBeAvailable(project: Project): Boolean = true + + private fun createChatPanel(project: Project, toolWindow: ToolWindow) { + val coroutineScope = project.service() + .createScope("AutoDevChatViewModel") + + val viewModel = AutoDevChatViewModel(coroutineScope) + Disposer.register(toolWindow.disposable, viewModel) + + toolWindow.addComposeTab("Chat") { + AutoDevChatApp(viewModel) + } + } +} + diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000000..ee37ccf75a --- /dev/null +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,30 @@ + + + cc.unitmesh.devins.idea + AutoDev Compose UI + UnitMesh + + + + + + + + + messages.AutoDevIdeaBundle + + + + + + + + + diff --git a/mpp-idea/src/main/resources/icons/autodev-toolwindow.svg b/mpp-idea/src/main/resources/icons/autodev-toolwindow.svg new file mode 100644 index 0000000000..01d6213962 --- /dev/null +++ b/mpp-idea/src/main/resources/icons/autodev-toolwindow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mpp-idea/src/main/resources/icons/autodev.svg b/mpp-idea/src/main/resources/icons/autodev.svg new file mode 100644 index 0000000000..d78018638d --- /dev/null +++ b/mpp-idea/src/main/resources/icons/autodev.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mpp-idea/src/main/resources/messages/AutoDevIdeaBundle.properties b/mpp-idea/src/main/resources/messages/AutoDevIdeaBundle.properties new file mode 100644 index 0000000000..622e6fbb27 --- /dev/null +++ b/mpp-idea/src/main/resources/messages/AutoDevIdeaBundle.properties @@ -0,0 +1,17 @@ +# AutoDev Compose UI Bundle +toolwindow.name=AutoDev Compose +toolwindow.title=AutoDev Chat + +# Chat UI +chat.placeholder=Start a conversation with your AI Assistant! +chat.input.placeholder=Type your message here... +chat.send=Send +chat.stop=Stop +chat.new=New Conversation + +# Settings +settings.ai.configure=Configure AI Provider +settings.ai.provider=Provider +settings.ai.model=Model +settings.ai.apikey=API Key + diff --git a/settings.gradle.kts b/settings.gradle.kts index 33a601c83a..66ffd465be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,7 @@ include("mpp-codegraph") include("mpp-server") include("mpp-viewer") include("mpp-viewer-web") +includeBuild("mpp-idea") include("core")