From ff8fd2943f51c01e17346f9479666261f472aa19 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:26:10 +0800 Subject: [PATCH 01/13] feat(mpp-idea): improve file search popup with proper PopupMenu pattern - Restructure IdeaFileSearchPopup to use Box container with trigger button - Add IdeaContextManager for context state management - Simplify popup content with search field at top - Add recent files display and file/folder search - Add new icons: CheckBox, CheckBoxOutlineBlank, Remove - Update IdeaTopToolbar to use new popup API --- .../devins/idea/editor/IdeaContextManager.kt | 251 +++++++++ .../devins/idea/editor/IdeaFileSearchPopup.kt | 514 ++++++++++-------- .../devins/idea/editor/IdeaTopToolbar.kt | 141 ++++- .../idea/toolwindow/IdeaComposeIcons.kt | 117 ++++ 4 files changed, 778 insertions(+), 245 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt new file mode 100644 index 0000000000..86596df46f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt @@ -0,0 +1,251 @@ +package cc.unitmesh.devins.idea.editor + +import com.intellij.codeInsight.lookup.LookupManagerListener +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages context files for the AI assistant. + * Provides state management for selected files, default context, and rules. + * + * Features: + * - Auto-add current editor file to context + * - Related classes suggestion via LookupManagerListener + * - Default context preset management + * - Context rules (file patterns, include/exclude) + */ +@Service(Service.Level.PROJECT) +class IdeaContextManager(private val project: Project) : Disposable { + + // Selected files in the current context + private val _selectedFiles = MutableStateFlow>(emptyList()) + val selectedFiles: StateFlow> = _selectedFiles.asStateFlow() + + // Default context files (saved preset) + private val _defaultContextFiles = MutableStateFlow>(emptyList()) + val defaultContextFiles: StateFlow> = _defaultContextFiles.asStateFlow() + + // Context rules + private val _rules = MutableStateFlow>(emptyList()) + val rules: StateFlow> = _rules.asStateFlow() + + // Related files suggested by the system + private val _relatedFiles = MutableStateFlow>(emptyList()) + val relatedFiles: StateFlow> = _relatedFiles.asStateFlow() + + // Auto-add current file setting + private val _autoAddCurrentFile = MutableStateFlow(true) + val autoAddCurrentFile: StateFlow = _autoAddCurrentFile.asStateFlow() + + // Listeners setup flag + private var listenersSetup = false + + init { + setupListeners() + } + + /** + * Setup editor and lookup listeners for auto-adding files + */ + private fun setupListeners() { + if (listenersSetup) return + listenersSetup = true + + // Listen to file editor changes + project.messageBus.connect(this).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + object : FileEditorManagerListener { + override fun selectionChanged(event: FileEditorManagerEvent) { + if (!_autoAddCurrentFile.value) return + val file = event.newFile ?: return + if (canBeAdded(file)) { + ApplicationManager.getApplication().invokeLater { + addRelatedFile(file) + } + } + } + } + ) + + // Initialize with current file + val currentFile = FileEditorManager.getInstance(project).selectedFiles.firstOrNull() + currentFile?.let { + if (canBeAdded(it)) { + addRelatedFile(it) + } + } + } + + /** + * Add a file to the selected context + */ + fun addFile(file: VirtualFile) { + if (!file.isValid) return + val current = _selectedFiles.value.toMutableList() + if (current.none { it.path == file.path }) { + current.add(file) + _selectedFiles.value = current + } + } + + /** + * Add multiple files to the selected context + */ + fun addFiles(files: List) { + val current = _selectedFiles.value.toMutableList() + files.filter { it.isValid && current.none { existing -> existing.path == it.path } } + .forEach { current.add(it) } + _selectedFiles.value = current + } + + /** + * Remove a file from the selected context + */ + fun removeFile(file: VirtualFile) { + _selectedFiles.value = _selectedFiles.value.filter { it.path != file.path } + } + + /** + * Clear all selected files + */ + fun clearContext() { + _selectedFiles.value = emptyList() + _relatedFiles.value = emptyList() + } + + /** + * Set the current selection as default context + */ + fun setAsDefaultContext() { + _defaultContextFiles.value = _selectedFiles.value.toList() + } + + /** + * Load the default context + */ + fun loadDefaultContext() { + val defaults = _defaultContextFiles.value + if (defaults.isNotEmpty()) { + _selectedFiles.value = defaults.filter { it.isValid } + } + } + + /** + * Clear the default context + */ + fun clearDefaultContext() { + _defaultContextFiles.value = emptyList() + } + + /** + * Check if default context is set + */ + fun hasDefaultContext(): Boolean = _defaultContextFiles.value.isNotEmpty() + + /** + * Add a related file (from editor listener or lookup) + */ + private fun addRelatedFile(file: VirtualFile) { + if (!file.isValid) return + val current = _relatedFiles.value.toMutableList() + if (current.none { it.path == file.path }) { + // Keep only the most recent 10 related files + if (current.size >= 10) { + current.removeAt(current.size - 1) + } + current.add(0, file) + _relatedFiles.value = current + } + } + + /** + * Add a context rule + */ + fun addRule(rule: ContextRule) { + val current = _rules.value.toMutableList() + current.add(rule) + _rules.value = current + } + + /** + * Remove a context rule + */ + fun removeRule(rule: ContextRule) { + _rules.value = _rules.value.filter { it.id != rule.id } + } + + /** + * Clear all rules + */ + fun clearRules() { + _rules.value = emptyList() + } + + /** + * Toggle auto-add current file setting + */ + fun setAutoAddCurrentFile(enabled: Boolean) { + _autoAddCurrentFile.value = enabled + } + + /** + * Check if a file can be added to context + */ + private fun canBeAdded(file: VirtualFile): Boolean { + if (!file.isValid) return false + if (file.isDirectory) return false + + // Skip binary files + val extension = file.extension?.lowercase() ?: "" + val binaryExtensions = setOf( + "jar", "class", "exe", "dll", "so", "dylib", + "png", "jpg", "jpeg", "gif", "ico", "pdf", + "zip", "tar", "gz", "rar", "7z" + ) + if (extension in binaryExtensions) return false + + return true + } + + override fun dispose() { + // Cleanup if needed + } + + companion object { + fun getInstance(project: Project): IdeaContextManager = project.service() + } +} + +/** + * Represents a context rule for filtering files + */ +data class ContextRule( + val id: String = java.util.UUID.randomUUID().toString(), + val name: String, + val type: ContextRuleType, + val pattern: String, + val enabled: Boolean = true +) + +/** + * Types of context rules + */ +enum class ContextRuleType { + INCLUDE_PATTERN, // Include files matching pattern + EXCLUDE_PATTERN, // Exclude files matching pattern + FILE_EXTENSION, // Filter by file extension + DIRECTORY // Include/exclude directory +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt index cd77397667..ec9a98b548 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt @@ -1,6 +1,10 @@ package cc.unitmesh.devins.idea.editor import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState @@ -8,19 +12,19 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.readAction import com.intellij.openapi.fileEditor.impl.EditorHistoryManager import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext + import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* @@ -28,15 +32,64 @@ import org.jetbrains.jewel.ui.component.* * Context menu popup for adding files/folders to workspace. * Uses Jewel's PopupMenu for native IntelliJ look and feel. * + * This component includes both the trigger button and the popup menu. + * The popup is positioned relative to the trigger button. + * * Layout: - * - Files (submenu with matching files) - * - Folders (submenu with matching folders) - * - Recently Opened Files (submenu) - * - Clear Context + * - Recently Opened Files (direct items) + * - Files (submenu with matching files, only when searching) + * - Folders (submenu with matching folders, only when searching) * - Search field at bottom */ @Composable fun IdeaFileSearchPopup( + project: Project, + showPopup: Boolean, + onShowPopupChange: (Boolean) -> Unit, + onFilesSelected: (List) -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Box(modifier = modifier) { + // Trigger button + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered || showPopup) + JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else + androidx.compose.ui.graphics.Color.Transparent + ) + .clickable { onShowPopupChange(true) } + .padding(4.dp) + ) { + Tooltip(tooltip = { Text("Add File to Context") }) { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + // Popup menu + if (showPopup) { + FileSearchPopupContent( + project = project, + onDismiss = { onShowPopupChange(false) }, + onFilesSelected = onFilesSelected + ) + } + } +} + +@Composable +private fun FileSearchPopupContent( project: Project, onDismiss: () -> Unit, onFilesSelected: (List) -> Unit @@ -44,35 +97,24 @@ fun IdeaFileSearchPopup( val searchQueryState = rememberTextFieldState("") val searchQuery by remember { derivedStateOf { searchQueryState.text.toString() } } - // Grouped search results - var files by remember { mutableStateOf>(emptyList()) } - var folders by remember { mutableStateOf>(emptyList()) } - var recentFiles by remember { mutableStateOf>(emptyList()) } - - // Submenu expansion states - var filesExpanded by remember { mutableStateOf(false) } - var foldersExpanded by remember { mutableStateOf(false) } - var recentExpanded by remember { mutableStateOf(false) } - - // Load data based on search query - run on background thread to avoid EDT blocking - LaunchedEffect(searchQuery) { - val results = withContext(Dispatchers.IO) { - if (searchQuery.length >= 2) { - searchAllItems(project, searchQuery) - } else { - SearchResults(emptyList(), emptyList(), loadRecentFiles(project)) - } + // Load recent files immediately (not in LaunchedEffect) + val recentFiles = remember(project) { loadRecentFiles(project) } + + // Search results - only computed when query is long enough + val searchResults = remember(searchQuery, project) { + if (searchQuery.length >= 2) { + searchAllItems(project, searchQuery) + } else { + null } - files = results.files - folders = results.folders - recentFiles = results.recentFiles } - // Initial load - run on background thread - LaunchedEffect(Unit) { - recentFiles = withContext(Dispatchers.IO) { - loadRecentFiles(project) - } + val files = searchResults?.files ?: emptyList() + val folders = searchResults?.folders ?: emptyList() + val filteredRecentFiles = if (searchQuery.length >= 2) { + searchResults?.recentFiles ?: emptyList() + } else { + recentFiles } PopupMenu( @@ -81,192 +123,152 @@ fun IdeaFileSearchPopup( true }, horizontalAlignment = Alignment.Start, - modifier = Modifier.widthIn(min = 280.dp, max = 450.dp) + modifier = Modifier.widthIn(min = 300.dp, max = 480.dp) ) { - // Files submenu - if (files.isNotEmpty() || searchQuery.length >= 2) { - submenu( - submenu = { - if (files.isEmpty()) { - passiveItem { - Text( - "No files found", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 13.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) - ) - ) - } - } else { - files.take(10).forEach { file -> - selectableItem( - selected = false, - onClick = { - onFilesSelected(listOf(file.virtualFile)) - onDismiss() - } - ) { - FileMenuItem(file) - } - } - if (files.size > 10) { - passiveItem { - Text( - "... and ${files.size - 10} more", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) - ) - ) - } + // Search field at top + passiveItem { + TextField( + state = searchQueryState, + placeholder = { Text("Search files...") }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + } + + separator() + + // Show search results if searching + if (searchQuery.length >= 2) { + // Files from search + if (files.isNotEmpty()) { + passiveItem { + Text( + "Files (${files.size})", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } + files.take(10).forEach { file -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(file.virtualFile)) + onDismiss() } + ) { + FileMenuItem(file) } } - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.InsertDriveFile, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) + if (files.size > 10) { + passiveItem { + Text( + "... and ${files.size - 10} more", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) ) - Text("Files", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) } } } - } - // Folders submenu - if (folders.isNotEmpty() || searchQuery.length >= 2) { - submenu( - submenu = { - if (folders.isEmpty()) { - passiveItem { - Text( - "No folders found", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 13.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) - ) - ) - } - } else { - folders.take(10).forEach { folder -> - selectableItem( - selected = false, - onClick = { - onFilesSelected(listOf(folder.virtualFile)) - onDismiss() - } - ) { - FolderMenuItem(folder) - } + // Folders from search + if (folders.isNotEmpty()) { + if (files.isNotEmpty()) separator() + passiveItem { + Text( + "Folders (${folders.size})", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } + folders.take(5).forEach { folder -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(folder.virtualFile)) + onDismiss() } + ) { + FolderMenuItem(folder) } } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.Folder, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) + } + + // No results message + if (files.isEmpty() && folders.isEmpty()) { + passiveItem { + Text( + "No files or folders found", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) ) - Text("Folders", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) } } - } - - // Recently Opened Files submenu - submenu( - submenu = { - if (recentFiles.isEmpty()) { - passiveItem { + } else { + // Show recent files when not searching + if (filteredRecentFiles.isNotEmpty()) { + passiveItem { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.History, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) Text( - "No recent files", + "Recent Files", style = JewelTheme.defaultTextStyle.copy( - fontSize = 13.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) ) ) } - } else { - recentFiles.take(15).forEach { file -> - selectableItem( - selected = false, - onClick = { - onFilesSelected(listOf(file.virtualFile)) - onDismiss() - } - ) { - FileMenuItem(file) + } + filteredRecentFiles.take(15).forEach { file -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(file.virtualFile)) + onDismiss() } + ) { + FileMenuItem(file) } } + } else { + passiveItem { + Text( + "No recent files. Type to search...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.History, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) - ) - Text("Recently Opened Files", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) - } - } - - separator() - - // Clear Context action - selectableItem( - selected = false, - onClick = { - onFilesSelected(emptyList()) - onDismiss() - } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.Close, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) - ) - Text("Clear Context", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) - } - } - - separator() - - // Search field at bottom - passiveItem { - TextField( - state = searchQueryState, - placeholder = { Text("Focus context") }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - ) } } } +/** + * File menu item with improved layout: + * - Icon on the left + * - Bold file name + * - Truncated path in gray (e.g., "...cc/unitmesh/devins/idea/editor") + * - History icon for recent files + */ @Composable private fun FileMenuItem(file: IdeaFilePresentation) { Row( @@ -274,25 +276,41 @@ private fun FileMenuItem(file: IdeaFilePresentation) { horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { + // File icon or history icon for recent files Icon( - imageVector = IdeaComposeIcons.InsertDriveFile, + imageVector = if (file.isRecentFile) IdeaComposeIcons.History else IdeaComposeIcons.InsertDriveFile, contentDescription = null, tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) - Column(modifier = Modifier.weight(1f)) { + + // File name (bold) and truncated path (gray) in a row + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Bold file name Text( text = file.name, - style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), - maxLines = 1 + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) + + // Truncated path in gray Text( - text = file.presentablePath, + text = file.truncatedPath, style = JewelTheme.defaultTextStyle.copy( fontSize = 11.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) ), - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) } } @@ -311,11 +329,34 @@ private fun FolderMenuItem(folder: IdeaFilePresentation) { tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) - Text( - text = folder.presentablePath, - style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), - maxLines = 1 - ) + + // Folder name (bold) and truncated path + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = folder.name, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = folder.truncatedPath, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } } } @@ -339,6 +380,27 @@ data class IdeaFilePresentation( val isRecentFile: Boolean = false, val isDirectory: Boolean = false ) { + /** + * Truncated path for display, e.g., "...cc/unitmesh/devins/idea/editor" + * Shows the parent directory path without the file name, truncated if too long. + */ + val truncatedPath: String + get() { + val parentPath = presentablePath.substringBeforeLast("/", "") + if (parentPath.isEmpty()) return "" + + // If path is short enough, show it as-is + if (parentPath.length <= 40) return parentPath + + // Truncate from the beginning with "..." + val parts = parentPath.split("/") + if (parts.size <= 2) return "...$parentPath" + + // Keep the last 3-4 parts of the path + val keepParts = parts.takeLast(4) + return "...${keepParts.joinToString("/")}" + } + companion object { fun from(project: Project, file: VirtualFile, isRecent: Boolean = false): IdeaFilePresentation { val basePath = project.basePath ?: "" @@ -364,14 +426,17 @@ private fun loadRecentFiles(project: Project): List { val recentFiles = mutableListOf() try { - val fileList = EditorHistoryManager.getInstance(project).fileList - fileList.take(30) - .filter { it.isValid && !it.isDirectory && canBeAdded(project, it) } - .forEach { file -> - recentFiles.add(IdeaFilePresentation.from(project, file, isRecent = true)) - } + ApplicationManager.getApplication().runReadAction { + val fileList = EditorHistoryManager.getInstance(project).fileList + fileList.take(30) + .filter { it.isValid && !it.isDirectory && canBeAdded(project, it) } + .forEach { file -> + recentFiles.add(IdeaFilePresentation.from(project, file, isRecent = true)) + } + } } catch (e: Exception) { - // Ignore errors loading recent files + com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") + .warn("Error loading recent files: ${e.message}", e) } return recentFiles @@ -385,7 +450,9 @@ private fun searchAllItems(project: Project, query: String): SearchResults { try { ApplicationManager.getApplication().runReadAction { - // Search files by name + val fileIndex = ProjectFileIndex.getInstance(project) + + // Search files by exact name match using FilenameIndex FilenameIndex.processFilesByName(query, false, scope) { file -> if (file.isDirectory) { if (folders.size < 20) { @@ -394,22 +461,31 @@ private fun searchAllItems(project: Project, query: String): SearchResults { } else if (canBeAdded(project, file) && files.size < 50) { files.add(IdeaFilePresentation.from(project, file)) } - files.size < 50 || folders.size < 20 + files.size < 50 && folders.size < 20 } - // Also search for folders containing the query - val fileIndex = ProjectFileIndex.getInstance(project) + // Also do fuzzy search by iterating project content + val existingFilePaths = files.map { it.path }.toSet() + val existingFolderPaths = folders.map { it.path }.toSet() + fileIndex.iterateContent { file -> - if (file.isDirectory && file.name.lowercase().contains(lowerQuery)) { - if (folders.size < 20 && !folders.any { it.path == file.path }) { - folders.add(IdeaFilePresentation.from(project, file)) + val nameLower = file.name.lowercase() + if (nameLower.contains(lowerQuery)) { + if (file.isDirectory) { + if (folders.size < 20 && file.path !in existingFolderPaths) { + folders.add(IdeaFilePresentation.from(project, file)) + } + } else if (canBeAdded(project, file) && files.size < 50 && file.path !in existingFilePaths) { + files.add(IdeaFilePresentation.from(project, file)) } } - folders.size < 20 + files.size < 50 && folders.size < 20 } } } catch (e: Exception) { - // Ignore search errors + // Log error for debugging + com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") + .warn("Error searching files: ${e.message}", e) } // Filter recent files by query diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index f9ea0fb258..ae7f4d1591 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons @@ -26,7 +27,12 @@ import org.jetbrains.jewel.ui.component.Tooltip * Top toolbar for the input section. * Contains @ trigger, file selection, and other context-related actions. * - * Layout: @ - / - Clipboard - Save - Cursor | Selected Files... | Add + * Layout: Add Button | Selected Files... | Context indicator + * + * Features: + * - Integrates with IdeaContextManager for state management + * - Shows selected files as chips with remove button on hover + * - Shows context indicator when default context or rules are active */ @Composable fun IdeaTopToolbar( @@ -41,6 +47,15 @@ fun IdeaTopToolbar( ) { var showFileSearchPopup by remember { mutableStateOf(false) } + // Get context manager state if project is available + val contextManager = remember(project) { project?.let { IdeaContextManager.getInstance(it) } } + val hasDefaultContext by contextManager?.defaultContextFiles?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + val rules by contextManager?.rules?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + val relatedFiles by contextManager?.relatedFiles?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + Row( modifier = modifier .fillMaxWidth() @@ -48,54 +63,128 @@ fun IdeaTopToolbar( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically ) { - // Left side: Action buttons + // Left side: Add button with popup Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically ) { - ToolbarIconButton( - onClick = { - if (project != null) { - showFileSearchPopup = true + // File search popup with trigger button + if (project != null) { + IdeaFileSearchPopup( + project = project, + showPopup = showFileSearchPopup, + onShowPopupChange = { showFileSearchPopup = it }, + onFilesSelected = { files -> + onFilesSelected(files) + showFileSearchPopup = false } - onAddFileClick() - }, - tooltip = "Add File" - ) { - Icon( - imageVector = IdeaComposeIcons.Add, - contentDescription = "Add File", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal + ) + } else { + ToolbarIconButton( + onClick = { onAddFileClick() }, + tooltip = "Add File to Context" + ) { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + // Context indicator: show if default context or rules are active + if (hasDefaultContext.isNotEmpty() || rules.isNotEmpty()) { + ContextIndicator( + hasDefaultContext = hasDefaultContext.isNotEmpty(), + rulesCount = rules.size ) } } - if (selectedFiles.isNotEmpty()) { + if (selectedFiles.isNotEmpty() || relatedFiles.isNotEmpty()) { Box(Modifier.width(1.dp).height(20.dp).background(JewelTheme.globalColors.borders.normal)) } + // Selected files as chips Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - selectedFiles.forEach { file -> + // Show selected files + selectedFiles.take(5).forEach { file -> FileChip(file = file, onRemove = { onRemoveFile(file) }) } + + // Show overflow indicator if more than 5 files + if (selectedFiles.size > 5) { + Text( + text = "+${selectedFiles.size - 5}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + } } } +} + +/** + * Context indicator showing active default context or rules + */ +@Composable +private fun ContextIndicator( + hasDefaultContext: Boolean, + rulesCount: Int +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + val tooltipText = buildString { + if (hasDefaultContext) append("Default context active") + if (hasDefaultContext && rulesCount > 0) append(" | ") + if (rulesCount > 0) append("$rulesCount rule(s) active") + } - // File search popup - if (showFileSearchPopup && project != null) { - IdeaFileSearchPopup( - project = project, - onDismiss = { showFileSearchPopup = false }, - onFilesSelected = { files -> - onFilesSelected(files) - showFileSearchPopup = false + Tooltip(tooltip = { Text(tooltipText) }) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f) + ) + .padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (hasDefaultContext) { + Icon( + imageVector = IdeaComposeIcons.Book, + contentDescription = "Default context", + modifier = Modifier.size(12.dp), + tint = JewelTheme.globalColors.text.info + ) } - ) + if (rulesCount > 0) { + Icon( + imageVector = IdeaComposeIcons.Settings, + contentDescription = "Rules", + modifier = Modifier.size(12.dp), + tint = JewelTheme.globalColors.text.info + ) + Text( + text = rulesCount.toString(), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index a188c29394..3b5608af71 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1582,5 +1582,122 @@ object IdeaComposeIcons { }.build() } + /** + * CheckBox icon (checked checkbox) + */ + val CheckBox: ImageVector by lazy { + ImageVector.Builder( + name = "CheckBox", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(19f, 3f) + horizontalLineTo(5f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(14f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(14f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(5f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(10f, 17f) + lineToRelative(-5f, -5f) + lineToRelative(1.41f, -1.41f) + lineTo(10f, 14.17f) + lineToRelative(7.59f, -7.59f) + lineTo(19f, 8f) + lineToRelative(-9f, 9f) + close() + } + }.build() + } + + /** + * CheckBoxOutlineBlank icon (unchecked checkbox) + */ + val CheckBoxOutlineBlank: ImageVector by lazy { + ImageVector.Builder( + name = "CheckBoxOutlineBlank", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(19f, 5f) + verticalLineToRelative(14f) + horizontalLineTo(5f) + verticalLineTo(5f) + horizontalLineToRelative(14f) + moveTo(19f, 3f) + horizontalLineTo(5f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(14f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(14f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(5f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + } + }.build() + } + + /** + * Remove icon (minus sign) + */ + val Remove: ImageVector by lazy { + ImageVector.Builder( + name = "Remove", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(19f, 13f) + horizontalLineTo(5f) + verticalLineToRelative(-2f) + horizontalLineToRelative(14f) + verticalLineToRelative(2f) + close() + } + }.build() + } + } From 3f827fc88eec9afa220c3aeadf98c743edf95348 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:29:23 +0800 Subject: [PATCH 02/13] fix(mpp-idea): fix folder selection and improve search UI - Add isDirectory field to SelectedFileItem - Use /dir: command for directories, /file: for files - Add Search icon to IdeaComposeIcons - Improve search field UI with icon and better padding - Update placeholder text to 'Search files and folders...' --- .../devins/idea/editor/IdeaFileSearchPopup.kt | 33 +++++++++++++--- .../devins/idea/editor/IdeaTopToolbar.kt | 13 ++++++- .../devins/idea/toolwindow/IdeaAgentApp.kt | 11 +++--- .../idea/toolwindow/IdeaComposeIcons.kt | 39 +++++++++++++++++++ 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt index ec9a98b548..8b1a7c636f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt @@ -125,13 +125,34 @@ private fun FileSearchPopupContent( horizontalAlignment = Alignment.Start, modifier = Modifier.widthIn(min = 300.dp, max = 480.dp) ) { - // Search field at top + // Search field at top with improved styling passiveItem { - TextField( - state = searchQueryState, - placeholder = { Text("Search files...") }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Search, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) + ) + TextField( + state = searchQueryState, + placeholder = { + Text( + "Search files and folders...", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + }, + modifier = Modifier.weight(1f) + ) + } } separator() diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index ae7f4d1591..0867aed1a3 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -251,6 +251,15 @@ data class SelectedFileItem( val name: String, val path: String, val icon: androidx.compose.ui.graphics.vector.ImageVector? = null, - val virtualFile: com.intellij.openapi.vfs.VirtualFile? = null -) + val virtualFile: com.intellij.openapi.vfs.VirtualFile? = null, + val isDirectory: Boolean = false +) { + /** + * Generate the DevIns command for this file/folder. + * Uses /dir: for directories and /file: for files. + */ + fun toDevInsCommand(): String { + return if (isDirectory) "/dir:$path" else "/file:$path" + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index cdbf724294..1f8a2cc42f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -349,7 +349,8 @@ private fun IdeaDevInInputArea( SelectedFileItem( name = vf.name, path = vf.path, - virtualFile = vf + virtualFile = vf, + isDirectory = vf.isDirectory ) } selectedFiles = (selectedFiles + newItems).distinctBy { it.path } @@ -376,8 +377,8 @@ private fun IdeaDevInInputArea( override fun onSubmit(text: String, trigger: IdeaInputTrigger) { if (text.isNotBlank() && !isProcessing) { - // Append file references to the message - val filesText = selectedFiles.joinToString("\n") { "/file:${it.path}" } + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } val fullText = if (filesText.isNotEmpty()) { "$text\n$filesText" } else { @@ -422,8 +423,8 @@ private fun IdeaDevInInputArea( onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() if (text.isNotBlank() && !isProcessing) { - // Append file references to the message - val filesText = selectedFiles.joinToString("\n") { "/file:${it.path}" } + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } val fullText = if (filesText.isNotEmpty()) { "$text\n$filesText" } else { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 3b5608af71..730f95be57 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1699,5 +1699,44 @@ object IdeaComposeIcons { }.build() } + /** + * Search icon (magnifying glass) + */ + val Search: ImageVector by lazy { + ImageVector.Builder( + name = "Search", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + // Magnifying glass icon + moveTo(15.5f, 14f) + horizontalLineToRelative(-0.79f) + lineToRelative(-0.28f, -0.27f) + curveTo(15.41f, 12.59f, 16f, 11.11f, 16f, 9.5f) + curveTo(16f, 5.91f, 13.09f, 3f, 9.5f, 3f) + reflectiveCurveTo(3f, 5.91f, 3f, 9.5f) + reflectiveCurveTo(5.91f, 16f, 9.5f, 16f) + curveToRelative(1.61f, 0f, 3.09f, -0.59f, 4.23f, -1.57f) + lineToRelative(0.27f, 0.28f) + verticalLineToRelative(0.79f) + lineToRelative(5f, 4.99f) + lineTo(20.49f, 19f) + lineToRelative(-4.99f, -5f) + close() + moveTo(9.5f, 14f) + curveTo(7.01f, 14f, 5f, 11.99f, 5f, 9.5f) + reflectiveCurveTo(7.01f, 5f, 9.5f, 5f) + reflectiveCurveTo(14f, 7.01f, 14f, 9.5f) + reflectiveCurveTo(11.99f, 14f, 9.5f, 14f) + close() + } + }.build() + } + } From 0dc18ee435ae322d9b7a02200886d0f478fcf255 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:33:15 +0800 Subject: [PATCH 03/13] fix(devins-lang): replace ClsFileImpl with PsiCompiledFile interface - Use PsiCompiledFile interface instead of ClsFileImpl implementation class - This fixes NoClassDefFoundError in some IDE configurations - Remove unnecessary null check for content --- .../language/compiler/exec/file/FileInsCommand.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt index f15cf3b37d..c756548053 100644 --- a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt @@ -13,8 +13,8 @@ import cc.unitmesh.devti.util.relativePath import com.intellij.openapi.application.runReadAction import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiCompiledFile import com.intellij.psi.PsiManager -import com.intellij.psi.impl.compiled.ClsFileImpl import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.PsiShortNamesCache @@ -65,23 +65,19 @@ class FileInsCommand(private val myProject: Project, private val prop: String) : val language = psiFile?.language?.displayName ?: "" val fileContent = when (psiFile) { - is ClsFileImpl -> { - psiFile.text + is PsiCompiledFile -> { + // For compiled files (like .class files), get the decompiled text + psiFile.decompiledPsiFile?.text ?: virtualFile.readText() } else -> { - runReadAction { virtualFile.readText() } + virtualFile.readText() } } Pair(fileContent, language) } - if (content == null) { - AutoDevNotifications.warn(myProject, "Cannot read file: $prop") - return "Cannot read file: $prop" - } - val fileContent = splitLines(range, content) val realPath = virtualFile.relativePath(myProject) From 444844d7fc22ada1f53f44c4972184deac653111 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:34:44 +0800 Subject: [PATCH 04/13] refactor(mpp-idea): move IdeaDevInInputArea to separate file Extract IdeaDevInInputArea composable from IdeaAgentApp.kt into its own file for better modularity and code organization. No functional changes. --- .../devins/idea/toolwindow/IdeaAgentApp.kt | 162 ----------------- .../idea/toolwindow/IdeaDevInInputArea.kt | 167 ++++++++++++++++++ 2 files changed, 167 insertions(+), 162 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index 1f8a2cc42f..de7c68a21e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -5,16 +5,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.unit.dp import cc.unitmesh.agent.AgentType -import cc.unitmesh.devins.idea.editor.IdeaBottomToolbar -import cc.unitmesh.devins.idea.editor.IdeaDevInInput -import cc.unitmesh.devins.idea.editor.IdeaInputListener -import cc.unitmesh.devins.idea.editor.IdeaInputTrigger import cc.unitmesh.devins.idea.editor.IdeaModelConfigDialogWrapper -import cc.unitmesh.devins.idea.editor.IdeaTopToolbar -import cc.unitmesh.devins.idea.editor.SelectedFileItem import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.components.header.IdeaAgentTabsHeader @@ -25,23 +18,16 @@ import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId import cc.unitmesh.devins.idea.components.status.IdeaToolLoadingStatusBar -import cc.unitmesh.devins.idea.components.timeline.CancelEvent import cc.unitmesh.devins.idea.components.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.llm.ModelConfig import cc.unitmesh.llm.NamedModelConfig -import com.intellij.openapi.Disposable -import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer import kotlinx.coroutines.CoroutineScope import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.component.Divider -import java.awt.BorderLayout -import java.awt.Dimension -import javax.swing.JPanel /** * Main Compose application for Agent ToolWindow. @@ -301,151 +287,3 @@ fun IdeaAgentApp( } } -/** - * Advanced chat input area with full DevIn language support. - * - * Uses IdeaDevInInput (EditorTextField-based) embedded via SwingPanel for: - * - DevIn language syntax highlighting and completion - * - IntelliJ's native completion popup integration - * - Enter to submit, Shift+Enter for newline - * - @ trigger for agent completion - * - Token usage display - * - Settings access - * - Stop/Send button based on execution state - * - Model selector for switching between LLM configurations - */ -@Composable -private fun IdeaDevInInputArea( - project: Project, - parentDisposable: Disposable, - isProcessing: Boolean, - onSend: (String) -> Unit, - onAbort: () -> Unit, - workspacePath: String? = null, - totalTokens: Int? = null, - onAtClick: () -> Unit = {}, - availableConfigs: List = emptyList(), - currentConfigName: String? = null, - onConfigSelect: (NamedModelConfig) -> Unit = {}, - onConfigureClick: () -> Unit = {} -) { - var inputText by remember { mutableStateOf("") } - var devInInput by remember { mutableStateOf(null) } - var selectedFiles by remember { mutableStateOf>(emptyList()) } - - Column( - modifier = Modifier.fillMaxSize().padding(8.dp) - ) { - // Top toolbar with file selection - IdeaTopToolbar( - project = project, - onAtClick = onAtClick, - selectedFiles = selectedFiles, - onRemoveFile = { file -> - selectedFiles = selectedFiles.filter { it.path != file.path } - }, - onFilesSelected = { files -> - val newItems = files.map { vf -> - SelectedFileItem( - name = vf.name, - path = vf.path, - virtualFile = vf, - isDirectory = vf.isDirectory - ) - } - selectedFiles = (selectedFiles + newItems).distinctBy { it.path } - } - ) - - // DevIn Editor via SwingPanel - uses weight(1f) to fill available space - SwingPanel( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - factory = { - val input = IdeaDevInInput( - project = project, - disposable = parentDisposable, - showAgent = true - ).apply { - recreateDocument() - - addInputListener(object : IdeaInputListener { - override fun editorAdded(editor: EditorEx) { - // Editor is ready - } - - override fun onSubmit(text: String, trigger: IdeaInputTrigger) { - if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() - } - } - - override fun onStop() { - onAbort() - } - - override fun onTextChanged(text: String) { - inputText = text - } - }) - } - - // Register for disposal - Disposer.register(parentDisposable, input) - devInInput = input - - // Wrap in a JPanel to handle dynamic sizing - JPanel(BorderLayout()).apply { - add(input, BorderLayout.CENTER) - // Don't set fixed preferredSize - let it fill available space - minimumSize = Dimension(200, 60) - } - }, - update = { panel -> - // Update panel if needed - } - ) - - // Bottom toolbar with Compose (MCP config is handled internally) - IdeaBottomToolbar( - onSendClick = { - val text = devInInput?.text?.trim() ?: inputText.trim() - if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - devInInput?.clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() - } - }, - sendEnabled = inputText.isNotBlank() && !isProcessing, - isExecuting = isProcessing, - onStopClick = onAbort, - totalTokens = totalTokens, - availableConfigs = availableConfigs, - currentConfigName = currentConfigName, - onConfigSelect = onConfigSelect, - onConfigureClick = onConfigureClick - ) - } -} - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt new file mode 100644 index 0000000000..be171bba68 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -0,0 +1,167 @@ +package cc.unitmesh.devins.idea.toolwindow + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.editor.* +import cc.unitmesh.llm.NamedModelConfig +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import java.awt.BorderLayout +import java.awt.Dimension +import javax.swing.JPanel + +/** + * Advanced chat input area with full DevIn language support. + * + * Uses IdeaDevInInput (EditorTextField-based) embedded via SwingPanel for: + * - DevIn language syntax highlighting and completion + * - IntelliJ's native completion popup integration + * - Enter to submit, Shift+Enter for newline + * - @ trigger for agent completion + * - Token usage display + * - Settings access + * - Stop/Send button based on execution state + * - Model selector for switching between LLM configurations + */ +@Composable +fun IdeaDevInInputArea( + project: Project, + parentDisposable: Disposable, + isProcessing: Boolean, + onSend: (String) -> Unit, + onAbort: () -> Unit, + workspacePath: String? = null, + totalTokens: Int? = null, + onAtClick: () -> Unit = {}, + availableConfigs: List = emptyList(), + currentConfigName: String? = null, + onConfigSelect: (NamedModelConfig) -> Unit = {}, + onConfigureClick: () -> Unit = {} +) { + var inputText by remember { mutableStateOf("") } + var devInInput by remember { mutableStateOf(null) } + var selectedFiles by remember { mutableStateOf>(emptyList()) } + + Column( + modifier = Modifier.Companion.fillMaxSize().padding(8.dp) + ) { + // Top toolbar with file selection + IdeaTopToolbar( + project = project, + onAtClick = onAtClick, + selectedFiles = selectedFiles, + onRemoveFile = { file -> + selectedFiles = selectedFiles.filter { it.path != file.path } + }, + onFilesSelected = { files -> + val newItems = files.map { vf -> + SelectedFileItem( + name = vf.name, + path = vf.path, + virtualFile = vf, + isDirectory = vf.isDirectory + ) + } + selectedFiles = (selectedFiles + newItems).distinctBy { it.path } + } + ) + + // DevIn Editor via SwingPanel - uses weight(1f) to fill available space + SwingPanel( + modifier = Modifier.Companion + .fillMaxWidth() + .weight(1f), + factory = { + val input = IdeaDevInInput( + project = project, + disposable = parentDisposable, + showAgent = true + ).apply { + recreateDocument() + + addInputListener(object : IdeaInputListener { + override fun editorAdded(editor: EditorEx) { + // Editor is ready + } + + override fun onSubmit(text: String, trigger: IdeaInputTrigger) { + if (text.isNotBlank() && !isProcessing) { + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } + val fullText = if (filesText.isNotEmpty()) { + "$text\n$filesText" + } else { + text + } + onSend(fullText) + clearInput() + inputText = "" + // Clear selected files after sending + selectedFiles = emptyList() + } + } + + override fun onStop() { + onAbort() + } + + override fun onTextChanged(text: String) { + inputText = text + } + }) + } + + // Register for disposal + Disposer.register(parentDisposable, input) + devInInput = input + + // Wrap in a JPanel to handle dynamic sizing + JPanel(BorderLayout()).apply { + add(input, BorderLayout.CENTER) + // Don't set fixed preferredSize - let it fill available space + minimumSize = Dimension(200, 60) + } + }, + update = { panel -> + // Update panel if needed + } + ) + + // Bottom toolbar with Compose (MCP config is handled internally) + IdeaBottomToolbar( + onSendClick = { + val text = devInInput?.text?.trim() ?: inputText.trim() + if (text.isNotBlank() && !isProcessing) { + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } + val fullText = if (filesText.isNotEmpty()) { + "$text\n$filesText" + } else { + text + } + onSend(fullText) + devInInput?.clearInput() + inputText = "" + // Clear selected files after sending + selectedFiles = emptyList() + } + }, + sendEnabled = inputText.isNotBlank() && !isProcessing, + isExecuting = isProcessing, + onStopClick = onAbort, + totalTokens = totalTokens, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick + ) + } +} \ No newline at end of file From 6e3b5682a175bbe905bab4cf08af25a1c6d403cd Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:41:48 +0800 Subject: [PATCH 05/13] feat(mpp-idea): redesign IdeaDevInInputArea layout with unified border - Add unified border around IdeaDevInInputArea for cohesive look - IdeaTopToolbar now supports horizontal scroll in collapsed mode - Add expand/collapse button to toggle between horizontal and vertical file list - FileChipExpanded shows full path in expanded mode - Use animateContentSize for smooth expand/collapse animation - Remove duplicate ExpandLess/ExpandMore icon definitions --- .../devins/idea/editor/IdeaTopToolbar.kt | 207 +++++++++++++----- .../idea/toolwindow/IdeaDevInInputArea.kt | 25 ++- 2 files changed, 175 insertions(+), 57 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index 0867aed1a3..08206617f5 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -1,12 +1,15 @@ package cc.unitmesh.devins.idea.editor +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -27,11 +30,14 @@ import org.jetbrains.jewel.ui.component.Tooltip * Top toolbar for the input section. * Contains @ trigger, file selection, and other context-related actions. * - * Layout: Add Button | Selected Files... | Context indicator + * Layout: + * - Collapsed mode: Add Button | [Horizontal scrollable file chips] | Expand button + * - Expanded mode: Add Button | [Vertical list of all files] | Collapse button * * Features: * - Integrates with IdeaContextManager for state management * - Shows selected files as chips with remove button on hover + * - Horizontal scroll in collapsed mode, vertical list in expanded mode * - Shows context indicator when default context or rules are active */ @Composable @@ -46,6 +52,7 @@ fun IdeaTopToolbar( modifier: Modifier = Modifier ) { var showFileSearchPopup by remember { mutableStateOf(false) } + var isExpanded by remember { mutableStateOf(false) } // Get context manager state if project is available val contextManager = remember(project) { project?.let { IdeaContextManager.getInstance(it) } } @@ -56,76 +63,115 @@ fun IdeaTopToolbar( val relatedFiles by contextManager?.relatedFiles?.collectAsState() ?: remember { mutableStateOf(emptyList()) } - Row( + Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically + .animateContentSize() ) { - // Left side: Add button with popup + // Main toolbar row Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // File search popup with trigger button - if (project != null) { - IdeaFileSearchPopup( - project = project, - showPopup = showFileSearchPopup, - onShowPopupChange = { showFileSearchPopup = it }, - onFilesSelected = { files -> - onFilesSelected(files) - showFileSearchPopup = false + // Left side: Add button with popup + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // File search popup with trigger button + if (project != null) { + IdeaFileSearchPopup( + project = project, + showPopup = showFileSearchPopup, + onShowPopupChange = { showFileSearchPopup = it }, + onFilesSelected = { files -> + onFilesSelected(files) + showFileSearchPopup = false + } + ) + } else { + ToolbarIconButton( + onClick = { onAddFileClick() }, + tooltip = "Add File to Context" + ) { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) } + } + + // Context indicator: show if default context or rules are active + if (hasDefaultContext.isNotEmpty() || rules.isNotEmpty()) { + ContextIndicator( + hasDefaultContext = hasDefaultContext.isNotEmpty(), + rulesCount = rules.size + ) + } + } + + // Separator + if (selectedFiles.isNotEmpty() || relatedFiles.isNotEmpty()) { + Box( + Modifier + .width(1.dp) + .height(20.dp) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) ) + } + + // Selected files - horizontal scrollable in collapsed mode + if (!isExpanded && selectedFiles.isNotEmpty()) { + val scrollState = rememberScrollState() + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + selectedFiles.forEach { file -> + FileChip(file = file, onRemove = { onRemoveFile(file) }) + } + } + } else if (!isExpanded) { + Spacer(Modifier.weight(1f)) } else { + Spacer(Modifier.weight(1f)) + } + + // Expand/Collapse button - only show if there are files + if (selectedFiles.size > 1) { ToolbarIconButton( - onClick = { onAddFileClick() }, - tooltip = "Add File to Context" + onClick = { isExpanded = !isExpanded }, + tooltip = if (isExpanded) "Collapse file list" else "Expand file list" ) { Icon( - imageVector = IdeaComposeIcons.Add, - contentDescription = "Add File", + imageVector = if (isExpanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", modifier = Modifier.size(16.dp), tint = JewelTheme.globalColors.text.normal ) } } - - // Context indicator: show if default context or rules are active - if (hasDefaultContext.isNotEmpty() || rules.isNotEmpty()) { - ContextIndicator( - hasDefaultContext = hasDefaultContext.isNotEmpty(), - rulesCount = rules.size - ) - } - } - - if (selectedFiles.isNotEmpty() || relatedFiles.isNotEmpty()) { - Box(Modifier.width(1.dp).height(20.dp).background(JewelTheme.globalColors.borders.normal)) } - // Selected files as chips - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Show selected files - selectedFiles.take(5).forEach { file -> - FileChip(file = file, onRemove = { onRemoveFile(file) }) - } - - // Show overflow indicator if more than 5 files - if (selectedFiles.size > 5) { - Text( - text = "+${selectedFiles.size - 5}", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) - ) - ) + // Expanded view - vertical list of all files + if (isExpanded && selectedFiles.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 8.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + selectedFiles.forEach { file -> + FileChipExpanded(file = file, onRemove = { onRemoveFile(file) }) + } } } } @@ -230,7 +276,7 @@ private fun FileChip(file: SelectedFileItem, onRemove: () -> Unit, modifier: Mod horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( - imageVector = file.icon ?: IdeaComposeIcons.InsertDriveFile, + imageVector = if (file.isDirectory) IdeaComposeIcons.Folder else (file.icon ?: IdeaComposeIcons.InsertDriveFile), contentDescription = null, modifier = Modifier.size(14.dp), tint = JewelTheme.globalColors.text.normal @@ -247,6 +293,61 @@ private fun FileChip(file: SelectedFileItem, onRemove: () -> Unit, modifier: Mod } } +/** + * Expanded file chip showing full path - used in vertical expanded mode + */ +@Composable +private fun FileChipExpanded(file: SelectedFileItem, onRemove: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered) JewelTheme.globalColors.panelBackground + else JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = if (file.isDirectory) IdeaComposeIcons.Folder else (file.icon ?: IdeaComposeIcons.InsertDriveFile), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = file.path, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = "Remove", + modifier = Modifier + .size(16.dp) + .clickable(onClick = onRemove), + tint = if (isHovered) JewelTheme.globalColors.text.normal else JewelTheme.globalColors.text.normal.copy(alpha = 0.4f) + ) + } +} + data class SelectedFileItem( val name: String, val path: String, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index be171bba68..197b8f52fb 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -1,12 +1,15 @@ package cc.unitmesh.devins.idea.toolwindow +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import cc.unitmesh.devins.idea.editor.* import cc.unitmesh.llm.NamedModelConfig @@ -14,6 +17,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension import javax.swing.JPanel @@ -30,6 +34,8 @@ import javax.swing.JPanel * - Settings access * - Stop/Send button based on execution state * - Model selector for switching between LLM configurations + * + * Layout: Unified border around the entire input area for a cohesive look. */ @Composable fun IdeaDevInInputArea( @@ -50,10 +56,21 @@ fun IdeaDevInInputArea( var devInInput by remember { mutableStateOf(null) } var selectedFiles by remember { mutableStateOf>(emptyList()) } + val borderShape = RoundedCornerShape(8.dp) + + // Outer container with unified border Column( - modifier = Modifier.Companion.fillMaxSize().padding(8.dp) + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .clip(borderShape) + .border( + width = 1.dp, + color = JewelTheme.globalColors.borders.normal, + shape = borderShape + ) ) { - // Top toolbar with file selection + // Top toolbar with file selection (no individual border) IdeaTopToolbar( project = project, onAtClick = onAtClick, @@ -76,7 +93,7 @@ fun IdeaDevInInputArea( // DevIn Editor via SwingPanel - uses weight(1f) to fill available space SwingPanel( - modifier = Modifier.Companion + modifier = Modifier .fillMaxWidth() .weight(1f), factory = { @@ -135,7 +152,7 @@ fun IdeaDevInInputArea( } ) - // Bottom toolbar with Compose (MCP config is handled internally) + // Bottom toolbar with Compose (no individual border) IdeaBottomToolbar( onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() From 3102194cf91077edba50906b32b50fd415747a2c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:58:59 +0800 Subject: [PATCH 06/13] feat(mpp-idea): improve MCP config dialog and implement prompt enhancement 1. Restyle IdeaMcpConfigDialog to match IdeaModelConfigDialog pattern: - Use styled Box container with rounded corners and proper background - Add proper tab selector with visual feedback - Improve spacing and visual hierarchy - Add Escape key handling for dialog dismissal - Extract IdeaMcpConfigDialogContent for reusability 2. Implement prompt enhancement feature: - Create IdeaPromptEnhancer service for AI-powered prompt optimization - Load domain dictionary from project's .autodev/domain.csv - Load README for project context - Use LLM to enhance prompts with domain-specific vocabulary - Add loading state indicator on enhancement button - Integrate with IdeaBottomToolbar and IdeaDevInInputArea --- .../devins/idea/editor/IdeaBottomToolbar.kt | 34 ++-- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 174 +++++++++++++----- .../devins/idea/editor/IdeaPromptEnhancer.kt | 152 +++++++++++++++ .../idea/toolwindow/IdeaDevInInputArea.kt | 25 +++ 4 files changed, 321 insertions(+), 64 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index b6118271d3..0afb57656c 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -1,18 +1,16 @@ package cc.unitmesh.devins.idea.editor -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import cc.unitmesh.llm.NamedModelConfig +import com.intellij.openapi.project.Project +import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.component.Icon @@ -29,11 +27,13 @@ import org.jetbrains.jewel.ui.component.Icon */ @Composable fun IdeaBottomToolbar( + project: Project? = null, onSendClick: () -> Unit, sendEnabled: Boolean, isExecuting: Boolean = false, onStopClick: () -> Unit = {}, onPromptOptimizationClick: () -> Unit = {}, + isEnhancing: Boolean = false, totalTokens: Int? = null, // Model selector props availableConfigs: List = emptyList(), @@ -95,16 +95,22 @@ fun IdeaBottomToolbar( } // Prompt Optimization button - IconButton( - onClick = onPromptOptimizationClick, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = IdeaComposeIcons.AutoAwesome, - contentDescription = "Prompt Optimization", - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) - ) + Tooltip({ + Text(if (isEnhancing) "Enhancing prompt..." else "Enhance prompt with AI") + }) { + IconButton( + onClick = onPromptOptimizationClick, + enabled = !isEnhancing && !isExecuting, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.AutoAwesome, + contentDescription = "Prompt Optimization", + tint = if (isEnhancing) JewelTheme.globalColors.text.info + else JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } } // Send or Stop button diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index 345a885664..ca18877ced 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -1,18 +1,29 @@ package cc.unitmesh.devins.idea.editor +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.flow.distinctUntilChanged import cc.unitmesh.agent.config.McpLoadingState import cc.unitmesh.agent.config.McpLoadingStateCallback @@ -21,10 +32,12 @@ import cc.unitmesh.agent.config.McpToolConfigManager import cc.unitmesh.agent.config.ToolConfigFile import cc.unitmesh.agent.config.ToolItem import cc.unitmesh.agent.mcp.McpServerConfig +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.config.ConfigManager import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* // JSON serialization helpers @@ -52,18 +65,34 @@ private fun deserializeMcpConfig(jsonString: String): Result Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + IdeaMcpConfigDialogContent(onDismiss = onDismiss) + } +} + +/** + * Content for the MCP configuration dialog. + * Extracted to be used both in Compose Dialog and DialogWrapper. + */ +@Composable +fun IdeaMcpConfigDialogContent( + onDismiss: () -> Unit ) { var toolConfig by remember { mutableStateOf(ToolConfigFile.default()) } var mcpTools by remember { mutableStateOf>>(emptyMap()) } @@ -84,7 +113,7 @@ fun IdeaMcpConfigDialog( hasUnsavedChanges = true autoSaveJob?.cancel() autoSaveJob = scope.launch { - kotlinx.coroutines.delay(2000) // Wait 2 seconds before auto-saving + kotlinx.coroutines.delay(2000) try { val enabledMcpTools = mcpTools.values .flatten() @@ -102,10 +131,9 @@ fun IdeaMcpConfigDialog( ConfigManager.saveToolConfig(updatedConfig) toolConfig = updatedConfig hasUnsavedChanges = false - println("✅ Auto-saved tool configuration") } } catch (e: Exception) { - println("❌ Auto-save failed: ${e.message}") + // Silent fail for auto-save } } } @@ -119,12 +147,9 @@ fun IdeaMcpConfigDialog( if (toolConfig.mcpServers.isNotEmpty()) { scope.launch { - // Create callback for incremental loading val callback = object : McpLoadingStateCallback { override fun onServerStateChanged(serverName: String, state: McpServerState) { mcpLoadingState = mcpLoadingState.updateServerState(serverName, state) - - // Update tools when server is loaded if (state.isLoaded) { mcpTools = mcpTools + (serverName to state.tools) } @@ -140,7 +165,6 @@ fun IdeaMcpConfigDialog( } try { - // Use incremental loading mcpLoadingState = McpToolConfigManager.discoverMcpToolsIncremental( toolConfig.mcpServers, toolConfig.enabledMcpTools.toSet(), @@ -149,35 +173,41 @@ fun IdeaMcpConfigDialog( mcpLoadError = null } catch (e: Exception) { mcpLoadError = "Failed to load MCP tools: ${e.message}" - println("❌ Error loading MCP tools: ${e.message}") } } } isLoading = false } catch (e: Exception) { - println("Error loading tool config: ${e.message}") mcpLoadError = "Failed to load configuration: ${e.message}" isLoading = false } } } - // Cancel auto-save job on dispose DisposableEffect(Unit) { onDispose { autoSaveJob?.cancel() } } - Dialog(onDismissRequest = onDismiss) { + Box( + modifier = Modifier + .width(600.dp) + .heightIn(max = 700.dp) + .clip(RoundedCornerShape(12.dp)) + .background(JewelTheme.globalColors.panelBackground) + .onKeyEvent { event -> + if (event.key == Key.Escape) { + onDismiss() + true + } else false + } + ) { Column( - modifier = Modifier - .width(800.dp) - .height(600.dp) - .padding(16.dp) + modifier = Modifier.padding(24.dp) ) { - // Header + // Title Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -187,55 +217,69 @@ fun IdeaMcpConfigDialog( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Tool Configuration") + Text( + text = "MCP Configuration", + style = JewelTheme.defaultTextStyle.copy(fontSize = 18.sp) + ) if (hasUnsavedChanges) { - Text("(Auto-saving...)", color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.info) + Text( + text = "(Saving...)", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) } } - IconButton(onClick = onDismiss) { - Text("×") - } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) if (isLoading) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center ) { - Text("Loading...") + Text("Loading configuration...") } } else { - // Tab Row + // Tab selector Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - DefaultButton( - onClick = { selectedTab = 0 }, - enabled = selectedTab != 0 - ) { - Text("Tools") - } - DefaultButton( - onClick = { selectedTab = 1 }, - enabled = selectedTab != 1 - ) { - Text("MCP Servers") - } + McpTabButton( + text = "Tools", + selected = selectedTab == 0, + onClick = { selectedTab = 0 } + ) + McpTabButton( + text = "MCP Servers", + selected = selectedTab == 1, + onClick = { selectedTab = 1 } + ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Error message mcpLoadError?.let { error -> - Text(error, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.error) + Text( + text = error, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.error + ) + ) Spacer(modifier = Modifier.height(8.dp)) } // Tab content - Box(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { when (selectedTab) { 0 -> McpToolsTab( mcpTools = mcpTools, @@ -271,7 +315,6 @@ fun IdeaMcpConfigDialog( val newServers = result.getOrThrow() toolConfig = toolConfig.copy(mcpServers = newServers) ConfigManager.saveToolConfig(toolConfig) - // Reload MCP tools try { val callback = object : McpLoadingStateCallback { override fun onServerStateChanged(serverName: String, state: McpServerState) { @@ -304,7 +347,7 @@ fun IdeaMcpConfigDialog( } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Footer Row( @@ -314,12 +357,16 @@ fun IdeaMcpConfigDialog( ) { val enabledMcp = mcpTools.values.flatten().count { it.enabled } val totalMcp = mcpTools.values.flatten().size - Text("MCP Tools: $enabledMcp/$totalMcp enabled") + Text( + text = "MCP Tools: $enabledMcp/$totalMcp enabled", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onDismiss) { - Text("Close") - } + OutlinedButton(onClick = onDismiss) { + Text("Close") } } } @@ -327,6 +374,33 @@ fun IdeaMcpConfigDialog( } } +@Composable +private fun McpTabButton( + text: String, + selected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background( + if (selected) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else androidx.compose.ui.graphics.Color.Transparent + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = if (selected) JewelTheme.globalColors.text.normal + else JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } +} + @Composable private fun McpToolsTab( mcpTools: Map>, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt new file mode 100644 index 0000000000..221ce5da83 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt @@ -0,0 +1,152 @@ +package cc.unitmesh.devins.idea.editor + +import cc.unitmesh.devins.ui.config.ConfigManager +import cc.unitmesh.llm.KoogLLMService +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Prompt enhancement service for IntelliJ IDEA. + * + * Enhances user prompts by: + * 1. Loading domain dictionary from project's prompts directory + * 2. Loading README file for project context + * 3. Using LLM to optimize the prompt with domain-specific vocabulary + * + * Based on core/src/main/kotlin/cc/unitmesh/devti/indexer/usage/PromptEnhancer.kt + */ +@Service(Service.Level.PROJECT) +class IdeaPromptEnhancer(private val project: Project) { + + /** + * Enhance the user's prompt using LLM. + * + * @param input The original user prompt + * @return The enhanced prompt, or the original if enhancement fails + */ + suspend fun enhance(input: String): String = withContext(Dispatchers.IO) { + try { + val dict = loadDomainDict() + val readme = loadReadme() + val prompt = buildEnhancePrompt(input, dict, readme) + + val config = ConfigManager.load() + val modelConfig = config.getActiveModelConfig() + ?: return@withContext input + + val llmService = KoogLLMService(modelConfig) + val result = StringBuilder() + + // Use streamPrompt with compileDevIns=false since we're sending a raw prompt + llmService.streamPrompt(prompt, compileDevIns = false).collect { chunk -> + result.append(chunk) + } + + extractEnhancedPrompt(result.toString()) ?: input + } catch (e: Exception) { + // Return original input if enhancement fails + input + } + } + + /** + * Load domain dictionary from project's prompts directory. + * Looks for domain.csv in the team prompts directory. + */ + private fun loadDomainDict(): String { + return try { + runReadAction { + val baseDir = project.guessProjectDir() ?: return@runReadAction "" + val promptsDir = baseDir.findChild(".autodev") ?: baseDir.findChild("prompts") + val dictFile = promptsDir?.findChild("domain.csv") + dictFile?.contentsToByteArray()?.toString(Charsets.UTF_8) ?: "" + } + } catch (e: Exception) { + "" + } + } + + /** + * Load README file from project root. + */ + private fun loadReadme(): String { + return try { + runReadAction { + val baseDir = project.guessProjectDir() ?: return@runReadAction "" + val readmeFile = baseDir.findChild("README.md") + ?: baseDir.findChild("README") + ?: baseDir.findChild("readme.md") + + val content = readmeFile?.contentsToByteArray()?.toString(Charsets.UTF_8) ?: "" + // Limit README content to avoid token overflow + if (content.length > 2000) content.take(2000) + "\n..." else content + } + } catch (e: Exception) { + "" + } + } + + /** + * Build the enhancement prompt. + * Based on core/src/main/resources/genius/en/code/enhance.vm + */ + private fun buildEnhancePrompt(input: String, dict: String, readme: String): String { + return buildString { + appendLine("You are a professional AI prompt optimization expert. Please help me optimize the following prompt and return it in the specified format.") + appendLine() + if (dict.isNotBlank()) { + appendLine("Here is a vocabulary reference provided by the user. Please only consider parts relevant to the user's question.") + appendLine() + appendLine("```csv") + appendLine(dict) + appendLine("```") + appendLine() + } + if (readme.isNotBlank()) { + appendLine("Here is the project's README information:") + appendLine("==========") + appendLine(readme) + appendLine("==========") + appendLine() + } + appendLine("Output format requirements:") + appendLine() + appendLine("- Return the result in a markdown code block for easy parsing") + appendLine("- The improved example should be in the same language as the user's prompt") + appendLine("- The improved example should be consistent with the information described in the user's prompt") + appendLine("- The output should only contain the improved example, without any other content") + appendLine("- Only include the improved example, do not add any other content or overly rich content") + appendLine("- Please do not make extensive associations, just enrich the vocabulary for the user's question") + appendLine() + appendLine("Now, the user's question is: $input") + } + } + + /** + * Extract the enhanced prompt from LLM response. + * Looks for content in markdown code blocks. + */ + private fun extractEnhancedPrompt(response: String): String? { + // Try to extract from markdown code block + val codeBlockRegex = Regex("```(?:\\w+)?\\s*\\n([\\s\\S]*?)\\n```") + val match = codeBlockRegex.find(response) + if (match != null) { + return match.groupValues[1].trim() + } + + // If no code block, return trimmed response if it's not too long + val trimmed = response.trim() + return if (trimmed.length < 500) trimmed else null + } + + companion object { + fun getInstance(project: Project): IdeaPromptEnhancer { + return project.getService(IdeaPromptEnhancer::class.java) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 197b8f52fb..659a47c645 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -17,6 +17,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension @@ -55,7 +56,9 @@ fun IdeaDevInInputArea( var inputText by remember { mutableStateOf("") } var devInInput by remember { mutableStateOf(null) } var selectedFiles by remember { mutableStateOf>(emptyList()) } + var isEnhancing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() val borderShape = RoundedCornerShape(8.dp) // Outer container with unified border @@ -154,6 +157,7 @@ fun IdeaDevInInputArea( // Bottom toolbar with Compose (no individual border) IdeaBottomToolbar( + project = project, onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() if (text.isNotBlank() && !isProcessing) { @@ -174,6 +178,27 @@ fun IdeaDevInInputArea( sendEnabled = inputText.isNotBlank() && !isProcessing, isExecuting = isProcessing, onStopClick = onAbort, + onPromptOptimizationClick = { + val currentText = devInInput?.text?.trim() ?: inputText.trim() + if (currentText.isNotBlank() && !isEnhancing && !isProcessing) { + isEnhancing = true + scope.launch { + try { + val enhancer = IdeaPromptEnhancer.getInstance(project) + val enhanced = enhancer.enhance(currentText) + if (enhanced != currentText) { + devInInput?.setText(enhanced) + inputText = enhanced + } + } catch (e: Exception) { + // Silently fail - keep original text + } finally { + isEnhancing = false + } + } + } + }, + isEnhancing = isEnhancing, totalTokens = totalTokens, availableConfigs = availableConfigs, currentConfigName = currentConfigName, From 4af8853676cf4aca9af4b76115099bdb67edd765 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:09:57 +0800 Subject: [PATCH 07/13] fix(mpp-idea): fix prompt enhancement functionality 1. Add replaceText() method to IdeaDevInInput for setting text content - Renamed from setText() to avoid conflict with EditorTextField.setText() - Uses WriteCommandAction for proper document modification 2. Fix prompt enhancement in IdeaDevInInputArea: - Use IntelliJ Logger instead of KLogger (not available in mpp-idea) - Move logger to file-level to avoid Composable context issues - Use Dispatchers.IO for LLM calls - Use ApplicationManager.invokeLater for EDT updates - Add proper logging for debugging 3. Add logging to IdeaPromptEnhancer: - Log enhancement progress and results - Log model configuration and LLM response details - Log errors with stack traces --- .../devins/idea/editor/IdeaDevInInput.kt | 11 +++++++ .../devins/idea/editor/IdeaPromptEnhancer.kt | 25 ++++++++++++-- .../idea/toolwindow/IdeaDevInInputArea.kt | 33 +++++++++++++++---- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt index 6ee6e1bac2..ca03f61685 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt @@ -242,6 +242,17 @@ class IdeaDevInInput( }) } + /** + * Replace the text content of the input. + * Clears existing content and sets new text. + */ + fun replaceText(newText: String) { + WriteCommandAction.runWriteCommandAction(project, "Replace text", "intentions.write.action", { + val document = this.editor?.document ?: return@runWriteCommandAction + document.setText(newText) + }) + } + /** * Clear the input and recreate document. */ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt index 221ce5da83..28875d593b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt @@ -4,6 +4,7 @@ import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.llm.KoogLLMService import com.intellij.openapi.application.runReadAction import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessProjectDir import kotlinx.coroutines.Dispatchers @@ -21,6 +22,7 @@ import kotlinx.coroutines.withContext */ @Service(Service.Level.PROJECT) class IdeaPromptEnhancer(private val project: Project) { + private val logger = Logger.getInstance(IdeaPromptEnhancer::class.java) /** * Enhance the user's prompt using LLM. @@ -30,13 +32,23 @@ class IdeaPromptEnhancer(private val project: Project) { */ suspend fun enhance(input: String): String = withContext(Dispatchers.IO) { try { + logger.info("Starting enhancement for input: ${input.take(50)}...") + val dict = loadDomainDict() val readme = loadReadme() + logger.info("Loaded domain dict (${dict.length} chars), readme (${readme.length} chars)") + val prompt = buildEnhancePrompt(input, dict, readme) + logger.info("Built enhancement prompt (${prompt.length} chars)") val config = ConfigManager.load() val modelConfig = config.getActiveModelConfig() - ?: return@withContext input + if (modelConfig == null) { + logger.warn("No active model config found, returning original input") + return@withContext input + } + + logger.info("Using model: ${modelConfig.modelName}") val llmService = KoogLLMService(modelConfig) val result = StringBuilder() @@ -46,8 +58,17 @@ class IdeaPromptEnhancer(private val project: Project) { result.append(chunk) } - extractEnhancedPrompt(result.toString()) ?: input + logger.info("LLM response received (${result.length} chars)") + val enhanced = extractEnhancedPrompt(result.toString()) + if (enhanced != null) { + logger.info("Extracted enhanced prompt (${enhanced.length} chars)") + } else { + logger.warn("Failed to extract enhanced prompt from response") + } + + enhanced ?: input } catch (e: Exception) { + logger.error("Enhancement failed: ${e.message}", e) // Return original input if enhancement fails input } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 659a47c645..65f45f4e34 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -14,10 +14,14 @@ import androidx.compose.ui.unit.dp import cc.unitmesh.devins.idea.editor.* import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension @@ -38,6 +42,8 @@ import javax.swing.JPanel * * Layout: Unified border around the entire input area for a cohesive look. */ +private val inputAreaLogger = Logger.getInstance("IdeaDevInInputArea") + @Composable fun IdeaDevInInputArea( project: Project, @@ -180,20 +186,35 @@ fun IdeaDevInInputArea( onStopClick = onAbort, onPromptOptimizationClick = { val currentText = devInInput?.text?.trim() ?: inputText.trim() + inputAreaLogger.info("Prompt optimization clicked, text length: ${currentText.length}") + if (currentText.isNotBlank() && !isEnhancing && !isProcessing) { isEnhancing = true - scope.launch { + scope.launch(Dispatchers.IO) { try { + inputAreaLogger.info("Starting prompt enhancement...") val enhancer = IdeaPromptEnhancer.getInstance(project) val enhanced = enhancer.enhance(currentText) - if (enhanced != currentText) { - devInInput?.setText(enhanced) - inputText = enhanced + inputAreaLogger.info("Enhancement completed, result length: ${enhanced.length}") + + if (enhanced != currentText && enhanced.isNotBlank()) { + // Update UI on EDT + withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { + devInInput?.replaceText(enhanced) + inputText = enhanced + inputAreaLogger.info("Text updated in input field") + } + } + } else { + inputAreaLogger.info("No enhancement made (same text or empty result)") } } catch (e: Exception) { - // Silently fail - keep original text + inputAreaLogger.error("Prompt enhancement failed: ${e.message}", e) } finally { - isEnhancing = false + withContext(Dispatchers.Main) { + isEnhancing = false + } } } } From b5737109b3e3188ee579f082cae4ec49064a1d79 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:17:26 +0800 Subject: [PATCH 08/13] feat(mpp-idea): use DialogWrapper for IdeaMcpConfigDialog to center in IDE - Create IdeaMcpConfigDialogWrapper using IntelliJ's DialogWrapper - Dialog now appears centered in the IDE window like IdeaModelConfigDialog - Proper z-index handling when used with SwingPanel components - Update IdeaBottomToolbar to use IdeaMcpConfigDialogWrapper.show() - Mark old IdeaMcpConfigDialog as deprecated --- .../devins/idea/editor/IdeaBottomToolbar.kt | 12 +---- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 0afb57656c..3db02028ff 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -42,7 +42,6 @@ fun IdeaBottomToolbar( onConfigureClick: () -> Unit = {}, modifier: Modifier = Modifier ) { - var showMcpConfigDialog by remember { mutableStateOf(false) } Row( modifier = modifier .fillMaxWidth() @@ -81,9 +80,9 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // MCP Config button - opens MCP configuration dialog + // MCP Config button - opens MCP configuration dialog using DialogWrapper IconButton( - onClick = { showMcpConfigDialog = true }, + onClick = { IdeaMcpConfigDialogWrapper.show(project) }, modifier = Modifier.size(32.dp) ) { Icon( @@ -162,12 +161,5 @@ fun IdeaBottomToolbar( } } } - - // MCP Configuration Dialog - if (showMcpConfigDialog) { - IdeaMcpConfigDialog( - onDismiss = { showMcpConfigDialog = false } - ) - } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index ca18877ced..3371695dde 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -24,6 +24,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.util.ui.JBUI import kotlinx.coroutines.flow.distinctUntilChanged import cc.unitmesh.agent.config.McpLoadingState import cc.unitmesh.agent.config.McpLoadingStateCallback @@ -37,8 +40,11 @@ import cc.unitmesh.devins.ui.config.ConfigManager import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.jetbrains.jewel.bridge.compose import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* +import java.awt.Dimension +import javax.swing.JComponent // JSON serialization helpers private val json = Json { @@ -63,6 +69,43 @@ private fun deserializeMcpConfig(jsonString: String): Result Date: Tue, 2 Dec 2025 22:30:06 +0800 Subject: [PATCH 09/13] ci(github-actions): free disk space in build workflow Add step to maximize available disk space in GitHub Actions by using jlumbroso/free-disk-space before fetching sources. --- .github/workflows/build.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 822379228c..3a8cc0c204 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -117,6 +117,13 @@ jobs: continue-on-error: true steps: # Check out the current repository + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + large-packages: false + - name: Fetch Sources uses: actions/checkout@v4 From d972a6f0349b092e84edf15737f7005a58b27e8f Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:34:13 +0800 Subject: [PATCH 10/13] fix(mpp-idea): address PR review comments 1. IdeaPromptEnhancer: - Log only metadata (length) instead of user prompt content to avoid sensitive info leakage - Fix domain dictionary fallback logic to properly check both paths - Add debug logging for file loading failures - Fix regex to handle code blocks without trailing newline 2. IdeaMcpConfigDialog: - Add proper @Deprecated annotation with ReplaceWith suggestion 3. IdeaBottomToolbar: - Remove unused kotlinx.coroutines.launch import 4. IdeaDevInInputArea: - Extract duplicate send logic into buildAndSendMessage helper function - Use isProcessingRef to fix stale closure issue in SwingPanel listener - Remove redundant withContext(Dispatchers.Main) + invokeLater combination --- .../devins/idea/editor/IdeaBottomToolbar.kt | 1 - .../devins/idea/editor/IdeaMcpConfigDialog.kt | 7 +- .../devins/idea/editor/IdeaPromptEnhancer.kt | 14 +-- .../idea/toolwindow/IdeaDevInInputArea.kt | 86 +++++++++++-------- 4 files changed, 66 insertions(+), 42 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 3db02028ff..949cc1f42e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -10,7 +10,6 @@ import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.project.Project -import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.component.Icon diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index 3371695dde..4207f54678 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -116,9 +116,12 @@ class IdeaMcpConfigDialogWrapper( * - Incremental MCP server loading * * Styled to match IdeaModelConfigDialog for consistency. - * - * @deprecated Use IdeaMcpConfigDialogWrapper.show() instead for proper z-index handling with SwingPanel. */ +@Deprecated( + message = "Use IdeaMcpConfigDialogWrapper.show() instead for proper z-index handling with SwingPanel.", + replaceWith = ReplaceWith("IdeaMcpConfigDialogWrapper.show(project)"), + level = DeprecationLevel.WARNING +) @Composable fun IdeaMcpConfigDialog( onDismiss: () -> Unit diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt index 28875d593b..cb9c6b5c13 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt @@ -32,7 +32,8 @@ class IdeaPromptEnhancer(private val project: Project) { */ suspend fun enhance(input: String): String = withContext(Dispatchers.IO) { try { - logger.info("Starting enhancement for input: ${input.take(50)}...") + // Log only metadata to avoid leaking sensitive information + logger.info("Starting enhancement for input (length: ${input.length})") val dict = loadDomainDict() val readme = loadReadme() @@ -82,11 +83,13 @@ class IdeaPromptEnhancer(private val project: Project) { return try { runReadAction { val baseDir = project.guessProjectDir() ?: return@runReadAction "" - val promptsDir = baseDir.findChild(".autodev") ?: baseDir.findChild("prompts") - val dictFile = promptsDir?.findChild("domain.csv") + // Try .autodev/domain.csv first, then prompts/domain.csv + val dictFile = baseDir.findChild(".autodev")?.findChild("domain.csv") + ?: baseDir.findChild("prompts")?.findChild("domain.csv") dictFile?.contentsToByteArray()?.toString(Charsets.UTF_8) ?: "" } } catch (e: Exception) { + logger.debug("Failed to load domain dictionary: ${e.message}") "" } } @@ -107,6 +110,7 @@ class IdeaPromptEnhancer(private val project: Project) { if (content.length > 2000) content.take(2000) + "\n..." else content } } catch (e: Exception) { + logger.debug("Failed to load README: ${e.message}") "" } } @@ -152,8 +156,8 @@ class IdeaPromptEnhancer(private val project: Project) { * Looks for content in markdown code blocks. */ private fun extractEnhancedPrompt(response: String): String? { - // Try to extract from markdown code block - val codeBlockRegex = Regex("```(?:\\w+)?\\s*\\n([\\s\\S]*?)\\n```") + // Try to extract from markdown code block (trailing newline is optional) + val codeBlockRegex = Regex("```(?:\\w+)?\\s*\\n([\\s\\S]*?)\\n?```") val match = codeBlockRegex.find(response) if (match != null) { return match.groupValues[1].trim() diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 65f45f4e34..6354f4077b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -21,7 +21,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension @@ -44,6 +43,26 @@ import javax.swing.JPanel */ private val inputAreaLogger = Logger.getInstance("IdeaDevInInputArea") +/** + * Helper function to build and send message with file references. + * Extracts common logic from onSubmit and onSendClick. + */ +private fun buildAndSendMessage( + text: String, + selectedFiles: List, + onSend: (String) -> Unit, + clearInput: () -> Unit, + clearFiles: () -> Unit +) { + if (text.isBlank()) return + + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } + val fullText = if (filesText.isNotEmpty()) "$text\n$filesText" else text + onSend(fullText) + clearInput() + clearFiles() +} + @Composable fun IdeaDevInInputArea( project: Project, @@ -64,6 +83,10 @@ fun IdeaDevInInputArea( var selectedFiles by remember { mutableStateOf>(emptyList()) } var isEnhancing by remember { mutableStateOf(false) } + // Use a ref to track current processing state for the SwingPanel listener + val isProcessingRef = remember { mutableStateOf(isProcessing) } + LaunchedEffect(isProcessing) { isProcessingRef.value = isProcessing } + val scope = rememberCoroutineScope() val borderShape = RoundedCornerShape(8.dp) @@ -119,19 +142,18 @@ fun IdeaDevInInputArea( } override fun onSubmit(text: String, trigger: IdeaInputTrigger) { - if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() + // Use ref to get current processing state + if (text.isNotBlank() && !isProcessingRef.value) { + buildAndSendMessage( + text = text, + selectedFiles = selectedFiles, + onSend = onSend, + clearInput = { + clearInput() + inputText = "" + }, + clearFiles = { selectedFiles = emptyList() } + ) } } @@ -167,18 +189,16 @@ fun IdeaDevInInputArea( onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - devInInput?.clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() + buildAndSendMessage( + text = text, + selectedFiles = selectedFiles, + onSend = onSend, + clearInput = { + devInInput?.clearInput() + inputText = "" + }, + clearFiles = { selectedFiles = emptyList() } + ) } }, sendEnabled = inputText.isNotBlank() && !isProcessing, @@ -198,13 +218,11 @@ fun IdeaDevInInputArea( inputAreaLogger.info("Enhancement completed, result length: ${enhanced.length}") if (enhanced != currentText && enhanced.isNotBlank()) { - // Update UI on EDT - withContext(Dispatchers.Main) { - ApplicationManager.getApplication().invokeLater { - devInInput?.replaceText(enhanced) - inputText = enhanced - inputAreaLogger.info("Text updated in input field") - } + // Update UI on EDT using invokeLater + ApplicationManager.getApplication().invokeLater { + devInInput?.replaceText(enhanced) + inputText = enhanced + inputAreaLogger.info("Text updated in input field") } } else { inputAreaLogger.info("No enhancement made (same text or empty result)") @@ -212,7 +230,7 @@ fun IdeaDevInInputArea( } catch (e: Exception) { inputAreaLogger.error("Prompt enhancement failed: ${e.message}", e) } finally { - withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { isEnhancing = false } } From 5b2f29608af8dd0f93ed47d652262fcc30a8d887 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:51:26 +0800 Subject: [PATCH 11/13] feat(mpp-idea): enhance IdeaMcpConfigDialog UI to match ToolConfigDialog 1. McpServersTab improvements: - Add header row with title and real-time JSON validation status - Show validation status indicator (Loading/Invalid JSON/Valid JSON) - Add styled error container with icon for error details - Add border around JSON editor with error state styling - Add footer with example hint and Save & Reload button with icon - Use CircularProgressIndicator for loading states 2. Fix Icon component usage: - Change 'key =' to 'imageVector =' for all Icon components - Replace IdeaComposeIcons.Schedule with IdeaComposeIcons.History 3. UI consistency: - Match the design patterns from ToolConfigDialog.kt - Use consistent spacing, colors, and typography --- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 658 ++++++++++++++---- 1 file changed, 535 insertions(+), 123 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index 4207f54678..f857379a08 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -1,48 +1,42 @@ package cc.unitmesh.devins.idea.editor import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties +import cc.unitmesh.agent.config.* +import cc.unitmesh.agent.mcp.McpServerConfig +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.config.ConfigManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper import com.intellij.util.ui.JBUI import kotlinx.coroutines.flow.distinctUntilChanged -import cc.unitmesh.agent.config.McpLoadingState -import cc.unitmesh.agent.config.McpLoadingStateCallback -import cc.unitmesh.agent.config.McpServerState -import cc.unitmesh.agent.config.McpToolConfigManager -import cc.unitmesh.agent.config.ToolConfigFile -import cc.unitmesh.agent.config.ToolItem -import cc.unitmesh.agent.mcp.McpServerConfig -import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons -import cc.unitmesh.devins.ui.config.ConfigManager import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.jetbrains.jewel.bridge.compose import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.Text import java.awt.Dimension import javax.swing.JComponent @@ -50,24 +44,50 @@ import javax.swing.JComponent private val json = Json { prettyPrint = true ignoreUnknownKeys = true + isLenient = true } private fun serializeMcpConfig(servers: Map): String { + if (servers.isEmpty()) return getDefaultMcpConfigTemplate() return try { json.encodeToString(servers) } catch (e: Exception) { - "{}" + getDefaultMcpConfigTemplate() } } private fun deserializeMcpConfig(jsonString: String): Result> { + if (jsonString.isBlank()) return Result.success(emptyMap()) return try { val servers = json.decodeFromString>(jsonString) + // Validate each server config + servers.forEach { (name, config) -> + if (!config.validate()) { + return Result.failure(Exception("Invalid config for '$name': must have 'command' or 'url'")) + } + } Result.success(servers) } catch (e: Exception) { - Result.failure(e) + Result.failure(Exception("Failed to parse JSON: ${e.message}")) + } +} + +private fun getDefaultMcpConfigTemplate(): String = """ +{ + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {} + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "" } + } } +""".trimIndent() /** * DialogWrapper for MCP configuration that uses IntelliJ's native dialog system. @@ -90,7 +110,7 @@ class IdeaMcpConfigDialogWrapper( val dialogPanel = compose { IdeaMcpConfigDialogContent(onDismiss = { close(CANCEL_EXIT_CODE) }) } - dialogPanel.preferredSize = Dimension(600, 600) + dialogPanel.preferredSize = Dimension(850, 650) return dialogPanel } @@ -106,34 +126,6 @@ class IdeaMcpConfigDialogWrapper( } } -/** - * MCP Configuration Dialog for IntelliJ IDEA. - * - * Features: - * - Two tabs: Tools and MCP Servers - * - Auto-save functionality (2 seconds delay) - * - Real-time JSON validation - * - Incremental MCP server loading - * - * Styled to match IdeaModelConfigDialog for consistency. - */ -@Deprecated( - message = "Use IdeaMcpConfigDialogWrapper.show() instead for proper z-index handling with SwingPanel.", - replaceWith = ReplaceWith("IdeaMcpConfigDialogWrapper.show(project)"), - level = DeprecationLevel.WARNING -) -@Composable -fun IdeaMcpConfigDialog( - onDismiss: () -> Unit -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - IdeaMcpConfigDialogContent(onDismiss = onDismiss) - } -} - /** * Content for the MCP configuration dialog. * Extracted to be used both in Compose Dialog and DialogWrapper. @@ -241,8 +233,8 @@ fun IdeaMcpConfigDialogContent( Box( modifier = Modifier - .width(600.dp) - .heightIn(max = 700.dp) + .width(850.dp) + .heightIn(max = 650.dp) .clip(RoundedCornerShape(12.dp)) .background(JewelTheme.globalColors.panelBackground) .onKeyEvent { event -> @@ -253,9 +245,9 @@ fun IdeaMcpConfigDialogContent( } ) { Column( - modifier = Modifier.padding(24.dp) + modifier = Modifier.fillMaxSize().padding(16.dp) ) { - // Title + // Title row with auto-save indicator Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -266,22 +258,49 @@ fun IdeaMcpConfigDialogContent( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "MCP Configuration", - style = JewelTheme.defaultTextStyle.copy(fontSize = 18.sp) + text = "Tool Configuration", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) ) if (hasUnsavedChanges) { - Text( - text = "(Saving...)", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = JewelTheme.globalColors.text.info - ) - ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.2f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.History, + contentDescription = "Auto-saving", + modifier = Modifier.size(14.dp), + tint = JewelTheme.globalColors.text.info + ) + Text( + text = "Auto-saving...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } } } + IconButton(onClick = onDismiss) { + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = "Close" + ) + } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) if (isLoading) { Box( @@ -291,34 +310,59 @@ fun IdeaMcpConfigDialogContent( Text("Loading configuration...") } } else { - // Tab selector + // Tab row - styled like Material TabRow Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.1f)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { McpTabButton( text = "Tools", selected = selectedTab == 0, - onClick = { selectedTab = 0 } + onClick = { selectedTab = 0 }, + modifier = Modifier.weight(1f) ) McpTabButton( text = "MCP Servers", selected = selectedTab == 1, - onClick = { selectedTab = 1 } + onClick = { selectedTab = 1 }, + modifier = Modifier.weight(1f) ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) - // Error message + // Error message with styled container mcpLoadError?.let { error -> - Text( - text = error, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = JewelTheme.globalColors.text.error - ) - ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFFFEBEE)) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + tint = Color(0xFFD32F2F), + modifier = Modifier.size(18.dp) + ) + Text( + text = error, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = Color(0xFFD32F2F) + ) + ) + } + } Spacer(modifier = Modifier.height(8.dp)) } @@ -395,26 +439,51 @@ fun IdeaMcpConfigDialogContent( } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) - // Footer + // Footer with summary and actions Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - val enabledMcp = mcpTools.values.flatten().count { it.enabled } - val totalMcp = mcpTools.values.flatten().size - Text( - text = "MCP Tools: $enabledMcp/$totalMcp enabled", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + // Summary column + Column(modifier = Modifier.weight(1f)) { + val enabledMcp = mcpTools.values.flatten().count { it.enabled } + val totalMcp = mcpTools.values.flatten().size + Text( + text = "MCP Tools: $enabledMcp/$totalMcp enabled | Built-in tools: Always enabled", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) ) - ) + if (hasUnsavedChanges) { + Text( + text = "Changes will be auto-saved in 2 seconds...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } else { + Text( + text = "All changes saved", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } + } - OutlinedButton(onClick = onDismiss) { - Text("Close") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + DefaultButton(onClick = onDismiss) { + Text("Apply & Close") + } } } } @@ -426,24 +495,27 @@ fun IdeaMcpConfigDialogContent( private fun McpTabButton( text: String, selected: Boolean, - onClick: () -> Unit + onClick: () -> Unit, + modifier: Modifier = Modifier ) { Box( - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(6.dp)) .background( - if (selected) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) - else androidx.compose.ui.graphics.Color.Transparent + if (selected) JewelTheme.globalColors.panelBackground + else Color.Transparent ) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center ) { Text( text = text, style = JewelTheme.defaultTextStyle.copy( fontSize = 13.sp, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, color = if (selected) JewelTheme.globalColors.text.normal - else JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + else JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) ) ) } @@ -455,27 +527,73 @@ private fun McpToolsTab( mcpLoadingState: McpLoadingState, onToolToggle: (String, Boolean) -> Unit ) { + val expandedServers = remember { mutableStateMapOf() } + LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp) + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - mcpTools.forEach { (serverName, tools) -> - item { - Text(serverName, modifier = Modifier.padding(vertical = 4.dp)) - } - items(tools) { tool -> + // Info banner about built-in tools + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.1f)) + .padding(12.dp) + ) { Row( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Column(modifier = Modifier.weight(1f)) { - Text(tool.displayName) - Text(tool.description, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.info) + Icon( + imageVector = IdeaComposeIcons.Info, + contentDescription = "Info", + tint = JewelTheme.globalColors.text.info, + modifier = Modifier.size(20.dp) + ) + Column { + Text( + text = "Built-in Tools Always Enabled", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = JewelTheme.globalColors.text.info + ) + ) + Text( + text = "File operations, search, shell, and other essential tools are always available", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) } - Checkbox( - checked = tool.enabled, - onCheckedChange = { onToolToggle(tool.name, it) } + } + } + } + + // MCP servers with tools + mcpTools.forEach { (serverName, tools) -> + val isExpanded = expandedServers.getOrPut(serverName) { true } + val serverState = mcpLoadingState.servers[serverName] + + item(key = "server_$serverName") { + McpServerHeader( + serverName = serverName, + serverState = serverState, + tools = tools, + isExpanded = isExpanded, + onToggle = { expandedServers[serverName] = !isExpanded } + ) + } + + if (isExpanded) { + items(tools, key = { "tool_${it.name}" }) { tool -> + CompactToolItemRow( + tool = tool, + onToggle = { enabled -> onToolToggle(tool.name, enabled) } ) } } @@ -485,13 +603,178 @@ private fun McpToolsTab( if (mcpTools.isEmpty() && !isLoading) { item { - Text("No MCP tools configured. Add MCP servers in the 'MCP Servers' tab.") + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No MCP tools configured. Add MCP servers in the 'MCP Servers' tab.", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + } } } if (isLoading) { item { - Text("Loading MCP tools...") + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Loading MCP tools...") + } + } + } + } +} + +@Composable +private fun McpServerHeader( + serverName: String, + serverState: McpServerState?, + tools: List, + isExpanded: Boolean, + onToggle: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.15f)) + .clickable(onClick = onToggle) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Status icon + val (statusIcon, statusColor) = when (serverState?.status) { + McpServerLoadingStatus.LOADING -> IdeaComposeIcons.Refresh to JewelTheme.globalColors.text.info + McpServerLoadingStatus.LOADED -> IdeaComposeIcons.Cloud to Color(0xFF4CAF50) + McpServerLoadingStatus.ERROR -> IdeaComposeIcons.Error to Color(0xFFD32F2F) + else -> IdeaComposeIcons.Cloud to JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + } + + Icon( + imageVector = statusIcon, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(18.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = "MCP: $serverName", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + modifier = Modifier.weight(1f) + ) + + if (tools.isNotEmpty()) { + Text( + text = "${tools.count { it.enabled }}/${tools.size}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + if (serverState?.isLoading == true) { + CircularProgressIndicator(modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(8.dp)) + } + + Icon( + imageVector = if (isExpanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f), + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun CompactToolItemRow( + tool: ToolItem, + onToggle: (Boolean) -> Unit +) { + var isChecked by remember { mutableStateOf(tool.enabled) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + if (isChecked) JewelTheme.globalColors.borders.normal.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable { + isChecked = !isChecked + onToggle(isChecked) + } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { + isChecked = it + onToggle(it) + } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Tool name + Text( + text = tool.displayName, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + modifier = Modifier.width(140.dp), + maxLines = 1 + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Description + Text( + text = tool.description, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ), + modifier = Modifier.weight(1f), + maxLines = 1 + ) + + // Server badge + if (tool.serverName.isNotEmpty()) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(2.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.2f)) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = tool.serverName, + style = JewelTheme.defaultTextStyle.copy(fontSize = 9.sp) + ) } } } @@ -529,34 +812,163 @@ private fun McpServersTab( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("MCP Server Configuration (JSON)") + // Header with title and validation status + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "MCP Server Configuration", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + ) + Text( + text = "JSON is validated in real-time", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + } + // Validation status indicator + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (isReloading) { + CircularProgressIndicator(modifier = Modifier.size(14.dp)) + Text( + text = "Loading...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } else if (errorMessage != null) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + tint = Color(0xFFD32F2F), + modifier = Modifier.size(14.dp) + ) + Text( + text = "Invalid JSON", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = Color(0xFFD32F2F) + ) + ) + } else if (mcpConfigJson.isNotBlank()) { + Icon( + imageVector = IdeaComposeIcons.CheckCircle, + contentDescription = "Valid", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(14.dp) + ) + Text( + text = "Valid JSON", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = Color(0xFF4CAF50) + ) + ) + } + } + } + + // Error message detail errorMessage?.let { error -> - Text(error, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.error) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFFFFEBEE)) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + tint = Color(0xFFD32F2F), + modifier = Modifier.size(16.dp) + ) + Text( + text = error, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = Color(0xFFD32F2F) + ) + ) + } + } } - // Use BasicTextField for multi-line text input - BasicTextField( - state = textFieldState, - modifier = Modifier.fillMaxWidth().weight(1f), - textStyle = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.normal - ), - cursorBrush = SolidColor(org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.normal) - ) + // JSON editor with border + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .border( + width = 1.dp, + color = if (errorMessage != null) Color(0xFFD32F2F) + else JewelTheme.globalColors.borders.normal, + shape = RoundedCornerShape(6.dp) + ) + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp) + ) { + BasicTextField( + state = textFieldState, + modifier = Modifier.fillMaxSize(), + textStyle = TextStyle( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal + ), + cursorBrush = SolidColor(JewelTheme.globalColors.text.normal) + ) + } + // Footer with hint and reload button Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { + Text( + text = "Example: uvx for Python tools, npx for Node.js tools", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + DefaultButton( onClick = onReload, enabled = !isReloading && errorMessage == null ) { - Text(if (isReloading) "Reloading..." else "Reload MCP Tools") + if (isReloading) { + CircularProgressIndicator(modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + } else { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text(if (isReloading) "Loading..." else "Save & Reload") } } } } - From 3eaccf53fad9254c9d803773c1f4252d56ed83af Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 23:03:34 +0800 Subject: [PATCH 12/13] feat(mpp-idea): add IdeaToolConfigService for tool config state management 1. Create IdeaToolConfigService: - Project-level service for managing tool configuration state - Provides StateFlow for observing config changes - Uses configVersion counter to trigger UI recomposition - Centralized save/load with notification to listeners 2. Update IdeaToolLoadingStatusBar: - Add project parameter - Observe configVersion from IdeaToolConfigService - Recompute toolStatus when config version changes 3. Update IdeaAgentViewModel: - Use IdeaToolConfigService for loading tool config - Get fresh config from service in getToolLoadingStatus() 4. Update IdeaMcpConfigDialog: - Add project parameter to IdeaMcpConfigDialogContent - Use IdeaToolConfigService.saveAndUpdateConfig() for auto-save - Notify listeners when tools are toggled 5. Register service in plugin.xml This ensures the status bar updates when MCP tools are enabled/disabled in the configuration dialog. --- .../status/IdeaToolLoadingStatusBar.kt | 12 +- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 19 ++- .../idea/services/IdeaToolConfigService.kt | 118 ++++++++++++++++++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 1 + .../idea/toolwindow/IdeaAgentViewModel.kt | 20 ++- .../src/main/resources/META-INF/plugin.xml | 2 + 6 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt index 1c27e4a70d..4b46dc92ec 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt @@ -12,8 +12,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.services.IdeaToolConfigService import cc.unitmesh.devins.idea.toolwindow.IdeaAgentViewModel import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.project.Project import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text @@ -23,12 +25,18 @@ import org.jetbrains.jewel.ui.component.Text @Composable fun IdeaToolLoadingStatusBar( viewModel: IdeaAgentViewModel, + project: Project, modifier: Modifier = Modifier ) { val mcpPreloadingMessage by viewModel.mcpPreloadingMessage.collectAsState() val mcpPreloadingStatus by viewModel.mcpPreloadingStatus.collectAsState() - // Recompute when preloading status changes to make it reactive - val toolStatus = remember(mcpPreloadingStatus) { viewModel.getToolLoadingStatus() } + + // Observe tool config service for configuration changes + val toolConfigService = remember { IdeaToolConfigService.getInstance(project) } + val configVersion by toolConfigService.configVersion.collectAsState() + + // Recompute when preloading status OR config version changes + val toolStatus = remember(mcpPreloadingStatus, configVersion) { viewModel.getToolLoadingStatus() } Row( modifier = modifier diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index f857379a08..fcbabe5e6c 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.agent.config.* import cc.unitmesh.agent.mcp.McpServerConfig +import cc.unitmesh.devins.idea.services.IdeaToolConfigService import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.config.ConfigManager import com.intellij.openapi.project.Project @@ -108,7 +109,10 @@ class IdeaMcpConfigDialogWrapper( override fun createCenterPanel(): JComponent { val dialogPanel = compose { - IdeaMcpConfigDialogContent(onDismiss = { close(CANCEL_EXIT_CODE) }) + IdeaMcpConfigDialogContent( + project = project, + onDismiss = { close(CANCEL_EXIT_CODE) } + ) } dialogPanel.preferredSize = Dimension(850, 650) return dialogPanel @@ -132,6 +136,7 @@ class IdeaMcpConfigDialogWrapper( */ @Composable fun IdeaMcpConfigDialogContent( + project: Project?, onDismiss: () -> Unit ) { var toolConfig by remember { mutableStateOf(ToolConfigFile.default()) } @@ -148,6 +153,11 @@ fun IdeaMcpConfigDialogContent( val scope = rememberCoroutineScope() + // Get tool config service for notifying state changes + val toolConfigService = remember(project) { + project?.let { IdeaToolConfigService.getInstance(it) } + } + // Auto-save function fun scheduleAutoSave() { hasUnsavedChanges = true @@ -168,7 +178,12 @@ fun IdeaMcpConfigDialogContent( mcpServers = newMcpServers ) - ConfigManager.saveToolConfig(updatedConfig) + // Use service to save and notify listeners + if (toolConfigService != null) { + toolConfigService.saveAndUpdateConfig(updatedConfig) + } else { + ConfigManager.saveToolConfig(updatedConfig) + } toolConfig = updatedConfig hasUnsavedChanges = false } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt new file mode 100644 index 0000000000..bb2a2de647 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt @@ -0,0 +1,118 @@ +package cc.unitmesh.devins.idea.services + +import cc.unitmesh.agent.config.ToolConfigFile +import cc.unitmesh.devins.ui.config.ConfigManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking + +/** + * Project-level service for managing tool configuration state. + * + * This service provides a centralized way to: + * 1. Load and cache tool configuration + * 2. Notify listeners when configuration changes + * 3. Track enabled/disabled MCP tools count + * + * Components like IdeaToolLoadingStatusBar and IdeaAgentViewModel can observe + * the toolConfigState to react to configuration changes. + */ +@Service(Service.Level.PROJECT) +class IdeaToolConfigService(private val project: Project) : Disposable { + + private val logger = Logger.getInstance(IdeaToolConfigService::class.java) + + // Tool configuration state + private val _toolConfigState = MutableStateFlow(ToolConfigState()) + val toolConfigState: StateFlow = _toolConfigState.asStateFlow() + + // Version counter to force recomposition when config changes + private val _configVersion = MutableStateFlow(0L) + val configVersion: StateFlow = _configVersion.asStateFlow() + + init { + // Load initial configuration + reloadConfig() + } + + /** + * Reload configuration from disk and update state. + * Uses runBlocking since this is called from non-suspend context. + */ + fun reloadConfig() { + try { + val toolConfig = runBlocking { ConfigManager.loadToolConfig() } + updateState(toolConfig) + logger.debug("Tool configuration reloaded: ${toolConfig.enabledMcpTools.size} enabled tools") + } catch (e: Exception) { + logger.warn("Failed to reload tool configuration: ${e.message}") + } + } + + /** + * Update the tool configuration state. + * Call this after saving configuration changes. + */ + fun updateState(toolConfig: ToolConfigFile) { + val enabledMcpToolsCount = toolConfig.enabledMcpTools.size + val mcpServersCount = toolConfig.mcpServers.filter { !it.value.disabled }.size + + _toolConfigState.value = ToolConfigState( + toolConfig = toolConfig, + enabledMcpToolsCount = enabledMcpToolsCount, + mcpServersCount = mcpServersCount, + lastUpdated = System.currentTimeMillis() + ) + + // Increment version to trigger recomposition + _configVersion.value++ + + logger.debug("Tool config state updated: $enabledMcpToolsCount enabled tools, $mcpServersCount servers") + } + + /** + * Save tool configuration and update state. + * Uses runBlocking since this is called from non-suspend context. + */ + fun saveAndUpdateConfig(toolConfig: ToolConfigFile) { + try { + runBlocking { ConfigManager.saveToolConfig(toolConfig) } + updateState(toolConfig) + logger.debug("Tool configuration saved and state updated") + } catch (e: Exception) { + logger.error("Failed to save tool configuration: ${e.message}") + } + } + + /** + * Get the current tool configuration. + */ + fun getToolConfig(): ToolConfigFile { + return _toolConfigState.value.toolConfig + } + + override fun dispose() { + // Cleanup if needed + } + + companion object { + fun getInstance(project: Project): IdeaToolConfigService = project.service() + } +} + +/** + * Data class representing the current tool configuration state. + */ +data class ToolConfigState( + val toolConfig: ToolConfigFile = ToolConfigFile.default(), + val enabledMcpToolsCount: Int = 0, + val mcpServersCount: Int = 0, + val lastUpdated: Long = 0L +) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index de7c68a21e..ceaa0284f8 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -245,6 +245,7 @@ fun IdeaAgentApp( // Tool loading status bar IdeaToolLoadingStatusBar( viewModel = viewModel, + project = project, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index af188e4f9b..1fbd874f5e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -12,6 +12,7 @@ import cc.unitmesh.agent.tool.schema.ToolCategory import cc.unitmesh.devins.compiler.service.DevInsCompilerService import cc.unitmesh.devins.idea.compiler.IdeaDevInsCompilerService import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.idea.services.IdeaToolConfigService import cc.unitmesh.devins.ui.config.AutoDevConfigWrapper import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.llm.KoogLLMService @@ -156,7 +157,11 @@ class IdeaAgentViewModel( private suspend fun startMcpPreloading() { try { _mcpPreloadingMessage.value = "Loading MCP servers configuration..." - val toolConfig = ConfigManager.loadToolConfig() + + // Use IdeaToolConfigService to get and cache tool config + val toolConfigService = IdeaToolConfigService.getInstance(project) + toolConfigService.reloadConfig() + val toolConfig = toolConfigService.getToolConfig() cachedToolConfig = toolConfig if (toolConfig.mcpServers.isEmpty()) { @@ -463,18 +468,25 @@ class IdeaAgentViewModel( /** * Get tool loading status. * Aligned with CodingAgentViewModel's getToolLoadingStatus(). + * Uses IdeaToolConfigService for up-to-date configuration. */ fun getToolLoadingStatus(): ToolLoadingStatus { - val toolConfig = cachedToolConfig + // Get fresh config from service to ensure we have latest changes + val toolConfigService = IdeaToolConfigService.getInstance(project) + val toolConfig = toolConfigService.getToolConfig() + + // Update cached config + cachedToolConfig = toolConfig + val subAgentTools = ToolType.byCategory(ToolCategory.SubAgent) val subAgentsEnabled = subAgentTools.size - val mcpServersTotal = toolConfig?.mcpServers?.filter { !it.value.disabled }?.size ?: 0 + val mcpServersTotal = toolConfig.mcpServers.filter { !it.value.disabled }.size val mcpServersLoaded = _mcpPreloadingStatus.value.preloadedServers.size val mcpToolsEnabled = if (McpToolConfigManager.isPreloading()) { 0 } else { - val enabledMcpToolsCount = toolConfig?.enabledMcpTools?.size ?: 0 + val enabledMcpToolsCount = toolConfig.enabledMcpTools.size if (enabledMcpToolsCount > 0) enabledMcpToolsCount else 0 } diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml index 6101d71f1c..7fa411017a 100644 --- a/mpp-idea/src/main/resources/META-INF/plugin.xml +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -58,6 +58,8 @@ + + Date: Tue, 2 Dec 2025 23:10:31 +0800 Subject: [PATCH 13/13] fix(devins-lang): fix memory leak in DevInsProgramRunner Problem: DevInsProgramRunner was implementing Disposable and registering a MessageBusConnection with 'this' as parent, but the runner itself was never properly disposed, causing memory leak warnings. Solution: - Remove Disposable interface from DevInsProgramRunner - Connect to project's message bus instead of application's - Register the connection with the project as parent disposable - This ensures proper cleanup when the project is closed The connection is now tied to the project lifecycle instead of the runner's lifecycle, which is the correct pattern for ProgramRunner implementations. --- .../devti/language/run/DevInsProgramRunner.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt index 9f73480b26..78107a3b13 100644 --- a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt @@ -13,17 +13,18 @@ import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.GenericProgramRunner import com.intellij.execution.runners.showRunContent import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.util.Disposer +import com.intellij.util.messages.MessageBusConnection import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicReference -class DevInsProgramRunner : GenericProgramRunner(), Disposable { +class DevInsProgramRunner : GenericProgramRunner() { private val RUNNER_ID: String = "DevInsProgramRunner" - private val connection = ApplicationManager.getApplication().messageBus.connect(this) - + // Use lazy initialization to avoid memory leak - connection is created per execution + // and tied to the project's lifecycle, not the runner's lifecycle + private var connection: MessageBusConnection? = null private var isSubscribed = false override fun getRunnerId(): String = RUNNER_ID @@ -40,7 +41,15 @@ class DevInsProgramRunner : GenericProgramRunner(), Disposable { ApplicationManager.getApplication().invokeAndWait { if (!isSubscribed) { - connection.subscribe(DevInsRunListener.TOPIC, object : DevInsRunListener { + // Connect to project's message bus instead of application's + // This ensures proper disposal when the project is closed + val projectConnection = environment.project.messageBus.connect() + connection = projectConnection + + // Register for disposal with the project + Disposer.register(environment.project, projectConnection) + + projectConnection.subscribe(DevInsRunListener.TOPIC, object : DevInsRunListener { override fun runFinish( allOutput: String, llmOutput: String, @@ -67,8 +76,4 @@ class DevInsProgramRunner : GenericProgramRunner(), Disposable { return result.get() } - - override fun dispose() { - connection.disconnect() - } }