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 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.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 org.jetbrains.jewel.foundation.theme.JewelTheme 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: * - 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 ) { val searchQueryState = rememberTextFieldState("") val searchQuery by remember { derivedStateOf { searchQueryState.text.toString() } } // 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 } } val files = searchResults?.files ?: emptyList() val folders = searchResults?.folders ?: emptyList() val filteredRecentFiles = if (searchQuery.length >= 2) { searchResults?.recentFiles ?: emptyList() } else { recentFiles } // Calculate item count for dynamic height val itemCount = when { searchQuery.length >= 2 -> { 1 + // search field (if (files.isNotEmpty()) 1 + minOf(files.size, 10) + (if (files.size > 10) 1 else 0) else 0) + (if (folders.isNotEmpty()) 1 + minOf(folders.size, 5) else 0) + (if (files.isEmpty() && folders.isEmpty()) 1 else 0) } else -> 1 + 1 + minOf(filteredRecentFiles.size, 15) + (if (filteredRecentFiles.isEmpty()) 1 else 0) } // Each item is approximately 32dp, plus some padding val estimatedHeight = (itemCount * 32 + 24).coerceIn(150, 450) PopupMenu( onDismissRequest = { onDismiss() true }, horizontalAlignment = Alignment.Start, modifier = Modifier .widthIn(min = 300.dp, max = 480.dp) .height(estimatedHeight.dp) ) { // Search field at top with improved styling passiveItem { 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() // 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) } } 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) ) ) } } } // 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) } } } // 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) ) ) } } } 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( "Recent Files", style = JewelTheme.defaultTextStyle.copy( fontSize = 11.sp, fontWeight = FontWeight.Bold, color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) ) ) } } 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) ) ) } } } } } /** * 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( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { // File icon or history icon for recent files Icon( imageVector = if (file.isRecentFile) IdeaComposeIcons.History else IdeaComposeIcons.InsertDriveFile, contentDescription = null, tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) // 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, fontWeight = FontWeight.Medium ), maxLines = 1, overflow = TextOverflow.Ellipsis ) // Truncated path in gray Text( text = file.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) ) } } } @Composable private fun FolderMenuItem(folder: IdeaFilePresentation) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = IdeaComposeIcons.Folder, contentDescription = null, tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) // 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) ) } } } /** * Search results grouped by type. */ data class SearchResults( val files: List, val folders: List, val recentFiles: List ) /** * File presentation data class for Compose UI. */ data class IdeaFilePresentation( val virtualFile: VirtualFile, val name: String, val path: String, val presentablePath: String, 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 ?: "" val relativePath = if (file.path.startsWith(basePath)) { file.path.removePrefix(basePath).removePrefix("/") } else { file.path } return IdeaFilePresentation( virtualFile = file, name = file.name, path = file.path, presentablePath = relativePath, isRecentFile = isRecent, isDirectory = file.isDirectory ) } } } private fun loadRecentFiles(project: Project): List { val recentFiles = mutableListOf() try { 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) { com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") .warn("Error loading recent files: ${e.message}", e) } return recentFiles } private fun searchAllItems(project: Project, query: String): SearchResults { val files = mutableListOf() val folders = mutableListOf() val scope = GlobalSearchScope.projectScope(project) val lowerQuery = query.lowercase() try { ApplicationManager.getApplication().runReadAction { 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) { folders.add(IdeaFilePresentation.from(project, file)) } } else if (canBeAdded(project, file) && files.size < 50) { files.add(IdeaFilePresentation.from(project, file)) } files.size < 50 && folders.size < 20 } // 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 -> 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)) } } files.size < 50 && folders.size < 20 } } } catch (e: Exception) { // Log error for debugging com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") .warn("Error searching files: ${e.message}", e) } // Filter recent files by query val recentFiles = loadRecentFiles(project).filter { it.name.lowercase().contains(lowerQuery) || it.presentablePath.lowercase().contains(lowerQuery) } return SearchResults( files = files.sortedBy { it.name }, folders = folders.sortedBy { it.presentablePath }, recentFiles = recentFiles ) } private fun canBeAdded(project: Project, file: VirtualFile): Boolean { if (!file.isValid) return false if (file.isDirectory) return true // Allow directories val fileIndex = ProjectFileIndex.getInstance(project) if (!fileIndex.isInContent(file)) return false if (fileIndex.isUnderIgnored(file)) 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") if (extension in binaryExtensions) return false return true }