diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt index 68592f71..31499083 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.application.runInEdt import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runUndoTransparentWriteAction import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.DocumentEvent @@ -20,6 +21,8 @@ import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.fileTypes.FileTypes import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.psi.codeStyle.MinusculeMatcher +import com.intellij.psi.codeStyle.NameUtil import com.intellij.ui.EditorTextField import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI @@ -48,28 +51,41 @@ class PromptTextField( private val onSubmit: (String) -> Unit, ) : EditorTextField(project, FileTypes.PLAIN_TEXT), Disposable { + companion object { + private val logger = thisLogger() + } + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var showSuggestionsJob: Job? = null + private var isInSearchContext = false + private var isInGroupLookupContext = false + private var lastSearchText: String? = null + private var lastSearchResults: List? = null val dispatcherId: UUID = UUID.randomUUID() var lookup: LookupImpl? = null init { + logger.info("PromptTextField initialized with dispatcherId: $dispatcherId") isOneLineMode = false IS_PROMPT_TEXT_FIELD_DOCUMENT.set(document, true) setPlaceholder(CodeGPTBundle.get("toolwindow.chat.textArea.emptyText")) } override fun onEditorAdded(editor: Editor) { + logger.info("Editor added for PromptTextField") IdeEventQueue.getInstance().addDispatcher( - PromptTextFieldEventDispatcher(dispatcherId, onBackSpace, lookup) { + PromptTextFieldEventDispatcher(dispatcherId, onBackSpace, lookup) { event -> val shown = lookup?.let { it.isShown && !it.isLookupDisposed } == true + logger.info("Submit attempt - lookup shown: $shown, text length: ${text.length}") if (shown) { + logger.info("Submit blocked due to active lookup") return@PromptTextFieldEventDispatcher } + logger.info("Submitting text: '${text.take(50)}${if (text.length > 50) "..." else ""}'") onSubmit(text) - it.consume() + event.consume() }, this ) @@ -82,6 +98,7 @@ class PromptTextField( } suspend fun showGroupLookup() { + logger.info("showGroupLookup() called") val lookupItems = listOf( FilesGroupItem(project, tagManager), FoldersGroupItem(project, tagManager), @@ -95,22 +112,33 @@ class PromptTextField( .map { it.createLookupElement() } .toTypedArray() + logger.info("Created ${lookupItems.size} group lookup items") + withContext(Dispatchers.Main) { editor?.let { + logger.info("Showing group lookup in main context") showGroupLookup(it, lookupItems) - } + } ?: logger.info("Editor is null, cannot show group lookup") } } private fun showGroupLookup(editor: Editor, lookupElements: Array) { + logger.info("showGroupLookup() called with ${lookupElements.size} elements") + isInGroupLookupContext = false + logger.info("Set isInGroupLookupContext = false") + lookup = createLookup(editor, lookupElements, "") + lookup?.addLookupListener(object : LookupListener { override fun itemSelected(event: LookupEvent) { val lookupString = event.item?.lookupString ?: return - val suggestion = - event.item?.getUserData(LookupItem.KEY) ?: return + val suggestion = event.item?.getUserData(LookupItem.KEY) ?: return + logger.info("Group lookup item selected: '$lookupString', suggestion type: ${suggestion::class.simpleName}") + val offset = editor.caretModel.offset val start = offset - lookupString.length + logger.info("Replacing text from $start to $offset") + if (start >= 0) { runUndoTransparentWriteAction { editor.document.deleteString(start, offset) @@ -118,19 +146,117 @@ class PromptTextField( } if (suggestion is WebActionItem) { + logger.info("WebActionItem selected, calling onLookupAdded") onLookupAdded(suggestion) } - if (suggestion !is LookupGroupItem) return + if (suggestion !is LookupGroupItem) { + logger.info("Selected item is not a LookupGroupItem, returning") + return + } + logger.info("Selected LookupGroupItem: ${suggestion::class.simpleName}") showSuggestionsJob?.cancel() showSuggestionsJob = coroutineScope.launch { + logger.info("Launching showGroupSuggestions coroutine") showGroupSuggestions(suggestion) } } + + override fun lookupCanceled(event: LookupEvent) { + logger.info("Group lookup canceled") + isInGroupLookupContext = false + } }) lookup?.refreshUi(false, true) lookup?.showLookup() + logger.info("Group lookup shown") + } + + private fun showGlobalSearchResults( + results: List, + searchText: String + ) { + logger.info("showGlobalSearchResults() called with ${results.size} results for search: '$searchText'") + editor?.let { editor -> + try { + val lookupElements = results.map { it.createLookupElement() }.toTypedArray() + logger.info("Created ${lookupElements.size} lookup elements") + + val existingLookup = lookup + if (existingLookup != null && existingLookup.isShown && !existingLookup.isLookupDisposed) { + logger.info("Hiding existing lookup before creating new one") + existingLookup.hide() + } + + logger.info("Creating new global search lookup") + lookup = createLookup(editor, lookupElements, "") + + lookup?.addLookupListener(object : LookupListener { + override fun itemSelected(event: LookupEvent) { + val lookupItem = event.item?.getUserData(LookupItem.KEY) ?: return + logger.info("Global search item selected: ${lookupItem::class.simpleName}") + + if (lookupItem !is LookupActionItem) { + logger.info("Selected item is not a LookupActionItem, returning") + return + } + + logger.info("Replacing @ symbol with search result") + replaceAtSymbolWithSearch(editor, lookupItem, searchText) + onLookupAdded(lookupItem) + } + }) + + lookup?.refreshUi(false, true) + lookup?.showLookup() + logger.info("Global search lookup shown") + } catch (e: Exception) { + logger.error("Error showing lookup: $e", e) + } + } ?: logger.info("Editor is null, cannot show global search results") + } + + private fun replaceAtSymbolWithSearch( + editor: Editor, + lookupItem: LookupItem, + searchText: String + ) { + logger.info("replaceAtSymbolWithSearch() called for item: ${lookupItem::class.simpleName}, searchText: '$searchText'") + val atPos = findAtSymbolPosition(editor) + logger.info("@ symbol position: $atPos") + + if (atPos >= 0) { + runUndoTransparentWriteAction { + val shouldInsertDisplayName = lookupItem is FileActionItem + || lookupItem is FolderActionItem + || lookupItem is GitCommitActionItem + logger.info("Should insert display name: $shouldInsertDisplayName") + + if (shouldInsertDisplayName) { + val endPos = atPos + 1 + searchText.length + logger.info("Replacing text from $atPos to $endPos with '${lookupItem.displayName}'") + editor.document.deleteString(atPos, endPos) + editor.document.insertString(atPos, lookupItem.displayName) + editor.caretModel.moveToOffset(atPos + lookupItem.displayName.length) + editor.markupModel.addRangeHighlighter( + atPos, + atPos + lookupItem.displayName.length, + HighlighterLayer.SELECTION, + TextAttributes().apply { + foregroundColor = JBColor(0x00627A, 0xCC7832) + }, + HighlighterTargetArea.EXACT_RANGE + ) + } else { + val endPos = atPos + 1 + searchText.length + logger.info("Deleting text from $atPos to $endPos") + editor.document.deleteString(atPos, endPos) + } + } + } else { + logger.info("@ symbol not found, cannot replace") + } } private fun findAtSymbolPosition(editor: Editor): Int { @@ -139,14 +265,20 @@ class PromptTextField( } private suspend fun showGroupSuggestions(group: LookupGroupItem) { + logger.info("showGroupSuggestions() called for group: ${group::class.simpleName}") val suggestions = group.getLookupItems() + logger.info("Retrieved ${suggestions.size} suggestions from group") + if (suggestions.isEmpty()) { + logger.info("No suggestions found, returning") return } val lookupElements = suggestions.map { it.createLookupElement() }.toTypedArray() + logger.info("Created ${lookupElements.size} lookup elements from suggestions") withContext(Dispatchers.Main) { + logger.info("Showing suggestion lookup in main context") showSuggestionLookup(lookupElements, group) } } @@ -170,6 +302,7 @@ class PromptTextField( filterText: String = "", ) { editor?.let { + isInGroupLookupContext = true lookup = createLookup(it, lookupElements, filterText) lookup?.addLookupListener(object : LookupListener { override fun itemSelected(event: LookupEvent) { @@ -180,6 +313,10 @@ class PromptTextField( onLookupAdded(lookupItem) } + override fun lookupCanceled(event: LookupEvent) { + isInGroupLookupContext = false + } + private fun replaceAtSymbol(editor: Editor, lookupItem: LookupItem) { val offset = editor.caretModel.offset val start = findAtSymbolPosition(editor) @@ -257,24 +394,212 @@ class PromptTextField( override fun dispose() { showSuggestionsJob?.cancel() + lastSearchResults = null } private fun setupDocumentListener(editor: EditorEx) { editor.document.addDocumentListener(object : DocumentListener { override fun documentChanged(event: DocumentEvent) { + logger.info("Document changed - offset: ${event.offset}, newLength: ${event.newLength}, fragment: '${event.newFragment}'") adjustHeight(editor) onTextChanged(event.document.text) + val text = editor.document.text + val caretOffset = event.offset + event.newLength + logger.info("Current text length: ${text.length}, caret offset: $caretOffset") + if ("@" == event.newFragment.toString()) { + logger.info("@ symbol detected, entering search context") + isInSearchContext = true + lastSearchText = "" + showSuggestionsJob?.cancel() + logger.info("Cancelled previous suggestions job") + showSuggestionsJob = coroutineScope.launch { + logger.info("Launching showGroupLookup coroutine") showGroupLookup() } + } else { + val searchText = getSearchTextAfterAt(text, caretOffset) + logger.info("Extracted search text: '$searchText'") + + when { + searchText != null && searchText.isEmpty() -> { + logger.info("Empty search text detected - reverting to group lookup") + if (!isInSearchContext || lastSearchText != searchText) { + logger.info("State change needed - updating to group lookup") + isInSearchContext = true + lastSearchText = searchText + isInGroupLookupContext = false + + showSuggestionsJob?.cancel() + logger.info("Cancelled previous job, launching updateLookupWithGroups") + showSuggestionsJob = coroutineScope.launch { + updateLookupWithGroups() + } + } else { + logger.info("No state change needed for empty search") + } + } + + !searchText.isNullOrEmpty() -> { + logger.info("Non-empty search text: '$searchText'") + // Skip global search logic if we're in a specific group lookup context + if (!isInGroupLookupContext) { + logger.info("Not in group lookup context, checking if matches default groups") + // Only trigger global search if searchText doesn't match any default groups + if (!matchesAnyDefaultGroup(searchText)) { + logger.info("Search text doesn't match default groups, triggering global search") + if (!isInSearchContext || lastSearchText != searchText) { + logger.info("State change needed for global search") + isInSearchContext = true + lastSearchText = searchText + logger.info("Updated search state: lastSearchText='$lastSearchText'") + + showSuggestionsJob?.cancel() + logger.info("Launching global search with 200ms delay") + showSuggestionsJob = coroutineScope.launch { + delay(200) + updateLookupWithSearchResults(searchText) + } + } else { + logger.info("No state change needed for global search") + } + } else { + logger.info("Search text matches default group, skipping global search") + } + } else { + logger.info("In group lookup context, skipping global search logic") + } + } + + searchText == null -> { + logger.info("No search text found, exiting search context") + if (isInSearchContext) { + logger.info("Was in search context, cleaning up") + isInSearchContext = false + isInGroupLookupContext = false + lastSearchText = null + + showSuggestionsJob?.cancel() + logger.info("Cancelled suggestions job") + + lookup?.let { existingLookup -> + if (!existingLookup.isLookupDisposed && existingLookup.isShown) { + logger.info("Hiding existing lookup") + runInEdt { existingLookup.hide() } + } + } + } + } + } } } }, this) } + private suspend fun updateLookupWithGroups() { + logger.info("updateLookupWithGroups() called") + val lookupItems = listOf( + FilesGroupItem(project, tagManager), + FoldersGroupItem(project, tagManager), + GitGroupItem(project), + PersonasGroupItem(tagManager), + DocsGroupItem(tagManager), + MCPGroupItem(), + WebActionItem(tagManager) + ) + .filter { it.enabled } + .map { it.createLookupElement() } + .toTypedArray() + + logger.info("Created ${lookupItems.size} group items for update") + + withContext(Dispatchers.Main) { + editor?.let { editor -> + logger.info("Editor available for group update") + lookup?.let { + logger.info("Existing lookup found - isShown: ${it.isShown}, isDisposed: ${it.isLookupDisposed}") + if (it.isShown && !it.isLookupDisposed) { + val wasShown = it.isShown + logger.info("Hiding existing lookup (wasShown: $wasShown)") + it.hide() + + if (wasShown) { + logger.info("Showing new group lookup after hiding previous") + showGroupLookup(editor, lookupItems) + } + } else { + logger.info("Showing group lookup (no previous lookup active)") + showGroupLookup(editor, lookupItems) + } + } ?: logger.info("No existing lookup, showing new group lookup") + } ?: logger.info("Editor not available for group update") + } + } + + private suspend fun updateLookupWithSearchResults(searchText: String) { + logger.info("updateLookupWithSearchResults() called with searchText: '$searchText'") + + val allGroups = listOf( + FilesGroupItem(project, tagManager), + FoldersGroupItem(project, tagManager), + GitGroupItem(project), + PersonasGroupItem(tagManager), + DocsGroupItem(tagManager), + MCPGroupItem() + ).filter { it.enabled } + + val allResults = mutableListOf() + allGroups.forEach { group -> + try { + val lookupActionItems = + group.getLookupItems("") + .filterIsInstance() // Get all items, filter later + allResults.addAll(lookupActionItems) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Error getting results from ${group::class.simpleName}", e) + } + } + + val webAction = WebActionItem(tagManager) + if (webAction.enabled()) { + allResults.add(webAction) + } + + val matcher: MinusculeMatcher = NameUtil.buildMatcher("*$searchText").build() + val matchedResults = allResults.mapNotNull { result -> + if (result is WebActionItem) { + if (searchText.contains("web", ignoreCase = true)) { + result to 100 + } else null + } else { + val matchingDegree = matcher.matchingDegree(result.displayName) + if (matchingDegree != Int.MIN_VALUE) { + result to matchingDegree + } else null + } + } + .sortedByDescending { it.second } + .map { it.first } + .take(100) // Limit results for better performance + + logger.info("Filtered to ${matchedResults.size} matching results for '$searchText'") + + // Only update lookup if results have actually changed + if (lastSearchResults != matchedResults) { + lastSearchResults = matchedResults + withContext(Dispatchers.Main) { + showGlobalSearchResults(matchedResults, searchText) + } + } else { + logger.info("Results unchanged, skipping lookup update") + } + } + private fun adjustHeight(editor: EditorEx) { val contentHeight = editor.contentComponent.preferredSize.height + 8 val maxHeight = JBUI.scale(getToolWindowHeight() / 2) @@ -291,4 +616,32 @@ class PromptTextField( return project.service() .getToolWindow("ProxyAI")?.component?.visibleRect?.height ?: 400 } + + private fun getSearchTextAfterAt(text: String, caretOffset: Int): String? { + val atPos = text.lastIndexOf('@') + if (atPos == -1 || atPos >= caretOffset) return null + + val searchText = text.substring(atPos + 1, caretOffset) + return if (searchText.contains(' ') || searchText.contains('\n')) { + null + } else { + searchText + } + } + + private fun matchesAnyDefaultGroup(searchText: String): Boolean { + val defaultGroupNames = listOf( + "files", "file", "f", + "folders", "folder", "fold", + "git", "g", + "personas", "persona", "p", + "docs", "doc", "d", + "mcp", "m", + "web", "w" + ) + + return defaultGroupNames.any { groupName -> + groupName.startsWith(searchText, ignoreCase = true) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt index 74fac726..ed7b79fd 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.codeStyle.NameUtil import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager @@ -46,9 +47,27 @@ class FilesGroupItem( override suspend fun getLookupItems(searchText: String): List { return readAction { val projectFileIndex = project.service() - project.service().openFiles - .filter { projectFileIndex.isInContent(it) && !containsTag(it) } - .toFileSuggestions() + val matcher = NameUtil.buildMatcher("*$searchText").build() + val matchingFiles = mutableListOf() + + projectFileIndex.iterateContent { file -> + if (!file.isDirectory && + !containsTag(file) && + (searchText.isEmpty() || matcher.matchingDegree(file.name) != Int.MIN_VALUE) + ) { + matchingFiles.add(file) + } + true + } + + val openFiles = project.service().openFiles + .filter { + projectFileIndex.isInContent(it) && + !containsTag(it) && + (searchText.isEmpty() || matcher.matchingDegree(it.name) != Int.MIN_VALUE) + } + + (matchingFiles + openFiles).distinctBy { it.path }.toFileSuggestions() } } @@ -59,7 +78,6 @@ class FilesGroupItem( private fun Iterable.toFileSuggestions(): List { val selectedFileTags = TagUtil.getExistingTags(project, FileTagDetails::class.java) return filter { file -> selectedFileTags.none { it.virtualFile == file } } - .take(10) .map { FileActionItem(project, it) } + listOf(IncludeOpenFilesActionItem()) } } \ No newline at end of file