From e0dbb030fbfc7055ad41d82ccd1f950e9553ac90 Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Fri, 6 Jun 2025 00:54:26 +0100 Subject: [PATCH] feat: global context search --- .../codegpt/ui/textarea/PromptTextField.kt | 366 +++++++++++------- .../ui/textarea/PromptTextFieldConstants.kt | 28 ++ .../textarea/PromptTextFieldLookupManager.kt | 238 ++++++++++++ .../codegpt/ui/textarea/SearchManager.kt | 110 ++++++ .../textarea/lookup/group/FilesGroupItem.kt | 26 +- 5 files changed, 614 insertions(+), 154 deletions(-) create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt 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..986354bf 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -1,27 +1,23 @@ package ee.carlrobert.codegpt.ui.textarea -import com.intellij.codeInsight.lookup.* +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupEvent +import com.intellij.codeInsight.lookup.LookupListener import com.intellij.codeInsight.lookup.impl.LookupImpl -import com.intellij.codeInsight.lookup.impl.PrefixChangeListener import com.intellij.ide.IdeEventQueue import com.intellij.openapi.Disposable 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 import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.editor.ex.EditorEx -import com.intellij.openapi.editor.markup.HighlighterLayer -import com.intellij.openapi.editor.markup.HighlighterTargetArea -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.ui.EditorTextField -import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT @@ -29,27 +25,30 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem -import ee.carlrobert.codegpt.ui.textarea.lookup.LookupItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.git.GitCommitActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.group.* import kotlinx.coroutines.* import java.awt.Dimension import java.util.* class PromptTextField( private val project: Project, - private val tagManager: TagManager, + tagManager: TagManager, private val onTextChanged: (String) -> Unit, private val onBackSpace: () -> Unit, private val onLookupAdded: (LookupActionItem) -> Unit, 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 val lookupManager = PromptTextFieldLookupManager(project, onLookupAdded) + private val searchManager = SearchManager(project, tagManager) + private var showSuggestionsJob: Job? = null + private var searchState = SearchState() + private var lastSearchResults: List? = null val dispatcherId: UUID = UUID.randomUUID() var lookup: LookupImpl? = null @@ -62,14 +61,14 @@ class PromptTextField( override fun onEditorAdded(editor: Editor) { IdeEventQueue.getInstance().addDispatcher( - PromptTextFieldEventDispatcher(dispatcherId, onBackSpace, lookup) { + PromptTextFieldEventDispatcher(dispatcherId, onBackSpace, lookup) { event -> val shown = lookup?.let { it.isShown && !it.isLookupDisposed } == true if (shown) { return@PromptTextFieldEventDispatcher } onSubmit(text) - it.consume() + event.consume() }, this ) @@ -82,63 +81,48 @@ class PromptTextField( } suspend fun showGroupLookup() { - val lookupItems = listOf( - FilesGroupItem(project, tagManager), - FoldersGroupItem(project, tagManager), - GitGroupItem(project), - PersonasGroupItem(tagManager), - DocsGroupItem(tagManager), - MCPGroupItem(), - WebActionItem(tagManager) - ) - .filter { it.enabled } + val lookupItems = searchManager.getDefaultGroups() .map { it.createLookupElement() } .toTypedArray() withContext(Dispatchers.Main) { - editor?.let { - showGroupLookup(it, lookupItems) + editor?.let { editor -> + lookup = lookupManager.showGroupLookup( + editor = editor, + lookupElements = lookupItems, + onGroupSelected = { group, selectedText -> + handleGroupSelected(group, selectedText) + }, + onWebActionSelected = { webAction -> + onLookupAdded(webAction) + } + ) } } } - private fun showGroupLookup(editor: Editor, lookupElements: Array) { - 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 offset = editor.caretModel.offset - val start = offset - lookupString.length - if (start >= 0) { - runUndoTransparentWriteAction { - editor.document.deleteString(start, offset) - } - } - - if (suggestion is WebActionItem) { - onLookupAdded(suggestion) - } - - if (suggestion !is LookupGroupItem) return - - showSuggestionsJob?.cancel() - showSuggestionsJob = coroutineScope.launch { - showGroupSuggestions(suggestion) - } + private fun showGlobalSearchResults( + results: List, + searchText: String + ) { + editor?.let { editor -> + try { + hideLookupIfShown() + lookup = lookupManager.showSearchResultsLookup(editor, results, searchText) + } catch (e: Exception) { + logger.error("Error showing lookup: $e", e) } - }) - lookup?.refreshUi(false, true) - lookup?.showLookup() + } } - private fun findAtSymbolPosition(editor: Editor): Int { - val atPos = editor.document.text.lastIndexOf('@') - return if (atPos >= 0) atPos else -1 + private fun handleGroupSelected(group: LookupGroupItem, searchText: String) { + showSuggestionsJob?.cancel() + showSuggestionsJob = coroutineScope.launch { + showGroupSuggestions(group, searchText) + } } - private suspend fun showGroupSuggestions(group: LookupGroupItem) { + private suspend fun showGroupSuggestions(group: LookupGroupItem, filterText: String = "") { val suggestions = group.getLookupItems() if (suggestions.isEmpty()) { return @@ -147,99 +131,53 @@ class PromptTextField( val lookupElements = suggestions.map { it.createLookupElement() }.toTypedArray() withContext(Dispatchers.Main) { - showSuggestionLookup(lookupElements, group) + showSuggestionLookup(lookupElements, group, filterText) } } - private fun createLookup( - editor: Editor, - lookupElements: Array, - searchText: String - ) = runReadAction { - LookupManager.getInstance(project).createLookup( - editor, - lookupElements, - searchText, - LookupArranger.DefaultArranger() - ) as LookupImpl - } - private fun showSuggestionLookup( lookupElements: Array, parentGroup: LookupGroupItem, filterText: String = "", ) { - editor?.let { - lookup = createLookup(it, lookupElements, filterText) + editor?.let { editor -> + searchState = searchState.copy(isInGroupLookupContext = true) + + lookup = lookupManager.showSuggestionLookup( + editor = editor, + lookupElements = lookupElements, + parentGroup = parentGroup, + onDynamicUpdate = { searchText -> + handleDynamicUpdate(parentGroup, lookupElements, searchText, filterText) + }, + filterText = filterText + ) + lookup?.addLookupListener(object : LookupListener { - override fun itemSelected(event: LookupEvent) { - val lookupItem = event.item?.getUserData(LookupItem.KEY) ?: return - if (lookupItem !is LookupActionItem) return - - replaceAtSymbol(it, lookupItem) - onLookupAdded(lookupItem) - } - - private fun replaceAtSymbol(editor: Editor, lookupItem: LookupItem) { - val offset = editor.caretModel.offset - val start = findAtSymbolPosition(editor) - if (start >= 0) { - runUndoTransparentWriteAction { - val shouldInsertDisplayName = lookupItem is FileActionItem - || lookupItem is FolderActionItem - || lookupItem is GitCommitActionItem - if (shouldInsertDisplayName) { - editor.document.deleteString(start, offset) - editor.document.insertString(start, lookupItem.displayName) - editor.caretModel.moveToOffset(start + lookupItem.displayName.length) - editor.markupModel.addRangeHighlighter( - start, - start + lookupItem.displayName.length, - HighlighterLayer.SELECTION, - TextAttributes().apply { - foregroundColor = JBColor(0x00627A, 0xCC7832) - }, - HighlighterTargetArea.EXACT_RANGE - ) - } else { - editor.document.deleteString(start, offset) - } - } - } + override fun lookupCanceled(event: LookupEvent) { + searchState = searchState.copy(isInGroupLookupContext = false) } }) + } + } - lookup?.addPrefixChangeListener(object : PrefixChangeListener { - override fun afterAppend(c: Char) { - showSuggestionsJob?.cancel() - showSuggestionsJob = coroutineScope.launch { - if (parentGroup is DynamicLookupGroupItem) { - val searchText = getSearchText() - if (searchText.length == 2) { - parentGroup.updateLookupList(lookup!!, searchText) - } - } + private fun handleDynamicUpdate( + parentGroup: LookupGroupItem, + lookupElements: Array, + searchText: String, + filterText: String + ) { + showSuggestionsJob?.cancel() + showSuggestionsJob = coroutineScope.launch { + if (parentGroup is DynamicLookupGroupItem) { + if (searchText.length >= PromptTextFieldConstants.MIN_DYNAMIC_SEARCH_LENGTH) { + parentGroup.updateLookupList(lookup!!, searchText) + } else if (searchText.isEmpty()) { + withContext(Dispatchers.Main) { + showSuggestionLookup(lookupElements, parentGroup, filterText) } } - - override fun afterTruncate() { - if (parentGroup is DynamicLookupGroupItem) { - val searchText = getSearchText() - if (searchText.isEmpty()) { - showSuggestionLookup(lookupElements, parentGroup, filterText) - } - } - } - - private fun getSearchText(): String { - val text = it.document.text - return text.substring(text.lastIndexOf("@") + 1) - } - - }, this) - - lookup?.refreshUi(false, true) - lookup?.showLookup() + } } } @@ -252,11 +190,17 @@ class PromptTextField( } override fun updateBorder(editor: EditorEx) { - editor.setBorder(JBUI.Borders.empty(4, 8)) + editor.setBorder( + JBUI.Borders.empty( + PromptTextFieldConstants.BORDER_PADDING, + PromptTextFieldConstants.BORDER_SIDE_PADDING + ) + ) } override fun dispose() { showSuggestionsJob?.cancel() + lastSearchResults = null } private fun setupDocumentListener(editor: EditorEx) { @@ -264,19 +208,140 @@ class PromptTextField( override fun documentChanged(event: DocumentEvent) { adjustHeight(editor) onTextChanged(event.document.text) - - if ("@" == event.newFragment.toString()) { - showSuggestionsJob?.cancel() - showSuggestionsJob = coroutineScope.launch { - showGroupLookup() - } - } + handleDocumentChange(event) } }, this) } + private fun handleDocumentChange(event: DocumentEvent) { + val text = event.document.text + val caretOffset = event.offset + event.newLength + + when { + isAtSymbolTyped(event) -> handleAtSymbolTyped() + else -> handleTextChange(text, caretOffset) + } + } + + private fun isAtSymbolTyped(event: DocumentEvent): Boolean { + return PromptTextFieldConstants.AT_SYMBOL == event.newFragment.toString() + } + + private fun handleAtSymbolTyped() { + searchState = searchState.copy( + isInSearchContext = true, + lastSearchText = "" + ) + + showSuggestionsJob?.cancel() + showSuggestionsJob = coroutineScope.launch { + showGroupLookup() + } + } + + private fun handleTextChange(text: String, caretOffset: Int) { + val searchText = searchManager.getSearchTextAfterAt(text, caretOffset) + + when { + searchText != null && searchText.isEmpty() -> handleEmptySearch() + !searchText.isNullOrEmpty() -> handleNonEmptySearch(searchText) + searchText == null -> handleNoSearch() + } + } + + private fun handleEmptySearch() { + if (!searchState.isInSearchContext || searchState.lastSearchText != "") { + searchState = searchState.copy( + isInSearchContext = true, + lastSearchText = "", + isInGroupLookupContext = false + ) + + showSuggestionsJob?.cancel() + showSuggestionsJob = coroutineScope.launch { + updateLookupWithGroups() + } + } + } + + private fun handleNonEmptySearch(searchText: String) { + if (!searchState.isInGroupLookupContext) { + if (!searchManager.matchesAnyDefaultGroup(searchText)) { + if (!searchState.isInSearchContext || searchState.lastSearchText != searchText) { + searchState = searchState.copy( + isInSearchContext = true, + lastSearchText = searchText + ) + + showSuggestionsJob?.cancel() + showSuggestionsJob = coroutineScope.launch { + delay(PromptTextFieldConstants.SEARCH_DELAY_MS) + updateLookupWithSearchResults(searchText) + } + } + } + } + } + + private fun handleNoSearch() { + if (searchState.isInSearchContext) { + searchState = SearchState() + showSuggestionsJob?.cancel() + hideLookupIfShown() + } + } + + private fun hideLookupIfShown() { + lookup?.let { existingLookup -> + if (!existingLookup.isLookupDisposed && existingLookup.isShown) { + runInEdt { existingLookup.hide() } + } + } + } + + private suspend fun updateLookupWithGroups() { + val lookupItems = searchManager.getDefaultGroups() + .map { it.createLookupElement() } + .toTypedArray() + + withContext(Dispatchers.Main) { + editor?.let { editor -> + lookup?.let { existingLookup -> + if (existingLookup.isShown && !existingLookup.isLookupDisposed) { + existingLookup.hide() + } + } + + lookup = lookupManager.showGroupLookup( + editor = editor, + lookupElements = lookupItems, + onGroupSelected = { group, currentSearchText -> + handleGroupSelected( + group, + currentSearchText + ) + }, + onWebActionSelected = { webAction -> onLookupAdded(webAction) }, + searchText = "" + ) + } + } + } + + private suspend fun updateLookupWithSearchResults(searchText: String) { + val matchedResults = searchManager.performGlobalSearch(searchText) + + if (lastSearchResults != matchedResults) { + lastSearchResults = matchedResults + withContext(Dispatchers.Main) { + showGlobalSearchResults(matchedResults, searchText) + } + } + } + private fun adjustHeight(editor: EditorEx) { - val contentHeight = editor.contentComponent.preferredSize.height + 8 + val contentHeight = + editor.contentComponent.preferredSize.height + PromptTextFieldConstants.HEIGHT_PADDING val maxHeight = JBUI.scale(getToolWindowHeight() / 2) val newHeight = minOf(contentHeight, maxHeight) @@ -289,6 +354,7 @@ class PromptTextField( private fun getToolWindowHeight(): Int { return project.service() - .getToolWindow("ProxyAI")?.component?.visibleRect?.height ?: 400 + .getToolWindow("ProxyAI")?.component?.visibleRect?.height + ?: PromptTextFieldConstants.DEFAULT_TOOL_WINDOW_HEIGHT } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt new file mode 100644 index 00000000..64eaf0b4 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt @@ -0,0 +1,28 @@ +package ee.carlrobert.codegpt.ui.textarea + +object PromptTextFieldConstants { + const val SEARCH_DELAY_MS = 200L + const val MIN_DYNAMIC_SEARCH_LENGTH = 2 + const val MAX_SEARCH_RESULTS = 100 + const val DEFAULT_TOOL_WINDOW_HEIGHT = 400 + const val BORDER_PADDING = 4 + const val BORDER_SIDE_PADDING = 8 + const val HEIGHT_PADDING = 8 + + val DEFAULT_GROUP_NAMES = listOf( + "files", "file", "f", + "folders", "folder", "fold", + "git", "g", + "personas", "persona", "p", + "docs", "doc", "d", + "mcp", "m", + "web", "w" + ) + + const val AT_SYMBOL = "@" + const val SPACE = " " + const val NEWLINE = "\n" + + const val LIGHT_THEME_COLOR = 0x00627A + const val DARK_THEME_COLOR = 0xCC7832 +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt new file mode 100644 index 00000000..a951df3d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt @@ -0,0 +1,238 @@ +package ee.carlrobert.codegpt.ui.textarea + +import com.intellij.codeInsight.lookup.* +import com.intellij.codeInsight.lookup.impl.LookupImpl +import com.intellij.codeInsight.lookup.impl.PrefixChangeListener +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.application.runUndoTransparentWriteAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.project.Project +import com.intellij.ui.JBColor +import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.action.git.GitCommitActionItem + +class PromptTextFieldLookupManager( + private val project: Project, + private val onLookupAdded: (LookupActionItem) -> Unit +) { + + fun createLookup( + editor: Editor, + lookupElements: Array, + searchText: String + ): LookupImpl = runReadAction { + LookupManager.getInstance(project).createLookup( + editor, + lookupElements, + searchText, + LookupArranger.DefaultArranger() + ) as LookupImpl + } + + fun showGroupLookup( + editor: Editor, + lookupElements: Array, + onGroupSelected: (group: LookupGroupItem, searchText: String) -> Unit, + onWebActionSelected: (WebActionItem) -> Unit, + searchText: String = "" + ): LookupImpl { + val lookup = createLookup(editor, lookupElements, "") + + lookup.addLookupListener(object : LookupListener { + override fun itemSelected(event: LookupEvent) { + val suggestion = event.item?.getUserData(LookupItem.KEY) ?: return + + replaceAtSymbol(editor, suggestion) + + when (suggestion) { + is WebActionItem -> onWebActionSelected(suggestion) + is LookupGroupItem -> onGroupSelected(suggestion, searchText) + } + } + }) + + lookup.refreshUi(false, true) + lookup.showLookup() + return lookup + } + + fun showSearchResultsLookup( + editor: Editor, + results: List, + searchText: String + ): LookupImpl { + val lookupElements = results.map { it.createLookupElement() }.toTypedArray() + val 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) as? LookupActionItem ?: return + + val offset = editor.caretModel.offset + val start = offset - lookupString.length + if (start >= 0) { + runUndoTransparentWriteAction { + editor.document.deleteString(start, offset) + } + } + + replaceAtSymbolWithSearch(editor, suggestion, searchText) + onLookupAdded(suggestion) + } + }) + + lookup.refreshUi(false, true) + lookup.showLookup() + return lookup + } + + fun showSuggestionLookup( + editor: Editor, + lookupElements: Array, + parentGroup: LookupGroupItem, + onDynamicUpdate: (String) -> Unit, + filterText: String = "" + ): LookupImpl { + val lookup = createLookup(editor, lookupElements, filterText) + + lookup.addLookupListener(object : LookupListener { + override fun itemSelected(event: LookupEvent) { + val lookupString = event.item?.lookupString ?: return + val suggestion = + event.item?.getUserData(LookupItem.KEY) as? LookupActionItem ?: return + + val offset = editor.caretModel.offset + val start = offset - lookupString.length + if (start >= 0) { + runUndoTransparentWriteAction { + editor.document.deleteString(start, offset) + } + } + + replaceAtSymbolWithSearch(editor, suggestion, filterText) + onLookupAdded(suggestion) + } + }) + + if (parentGroup is DynamicLookupGroupItem) { + setupDynamicLookupListener(lookup, onDynamicUpdate) + } + + lookup.refreshUi(false, true) + lookup.showLookup() + return lookup + } + + private fun setupDynamicLookupListener( + lookup: LookupImpl, + onDynamicUpdate: (String) -> Unit + ) { + lookup.addPrefixChangeListener(object : PrefixChangeListener { + override fun afterAppend(c: Char) { + val searchText = getSearchTextFromLookup(lookup) + if (searchText.length >= PromptTextFieldConstants.MIN_DYNAMIC_SEARCH_LENGTH) { + onDynamicUpdate(searchText) + } + } + + override fun afterTruncate() { + val searchText = getSearchTextFromLookup(lookup) + if (searchText.isEmpty()) { + onDynamicUpdate("") + } + } + }, lookup) + } + + private fun getSearchTextFromLookup(lookup: LookupImpl): String { + val editor = lookup.editor + val text = editor.document.text + val atIndex = text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL) + return if (atIndex >= 0) text.substring(atIndex + 1) else "" + } + + private fun getSearchTextFromEditor(editor: Editor): String { + val text = editor.document.text + val caretOffset = editor.caretModel.offset + val atIndex = text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL) + return if (atIndex >= 0 && atIndex < caretOffset) { + text.substring(atIndex + 1, caretOffset) + } else { + "" + } + } + + private fun replaceAtSymbolWithSearch( + editor: Editor, + lookupItem: LookupItem, + searchText: String + ) { + val atPos = findAtSymbolPosition(editor) + if (atPos >= 0) { + runUndoTransparentWriteAction { + val actualSearchText = getSearchTextFromEditor(editor) + val endPos = atPos + 1 + actualSearchText.length + editor.document.deleteString(atPos, endPos) + + if (shouldInsertDisplayName(lookupItem)) { + insertWithHighlight(editor, atPos, lookupItem.displayName) + } + } + } + } + + private fun replaceAtSymbol(editor: Editor, lookupItem: LookupItem) { + val offset = editor.caretModel.offset + val start = findAtSymbolPosition(editor) + if (start >= 0) { + runUndoTransparentWriteAction { + val shouldInsert = shouldInsertDisplayName(lookupItem) + if (shouldInsert) { + editor.document.deleteString(start, offset) + insertWithHighlight(editor, start, lookupItem.displayName) + } else { + editor.document.deleteString(start + 1, offset) + } + } + } + } + + private fun shouldInsertDisplayName(lookupItem: LookupItem): Boolean { + return lookupItem is FileActionItem + || lookupItem is FolderActionItem + || lookupItem is GitCommitActionItem + } + + private fun insertWithHighlight(editor: Editor, position: Int, text: String) { + editor.document.insertString(position, text) + editor.caretModel.moveToOffset(position + text.length) + editor.markupModel.addRangeHighlighter( + position, + position + text.length, + HighlighterLayer.SELECTION, + TextAttributes().apply { + foregroundColor = JBColor( + PromptTextFieldConstants.LIGHT_THEME_COLOR, + PromptTextFieldConstants.DARK_THEME_COLOR + ) + }, + HighlighterTargetArea.EXACT_RANGE + ) + } + + private fun findAtSymbolPosition(editor: Editor): Int { + val atPos = editor.document.text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL) + return if (atPos >= 0) atPos else -1 + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt new file mode 100644 index 00000000..effde265 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt @@ -0,0 +1,110 @@ +package ee.carlrobert.codegpt.ui.textarea + +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.psi.codeStyle.MinusculeMatcher +import com.intellij.psi.codeStyle.NameUtil +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.group.* +import kotlinx.coroutines.CancellationException + +data class SearchState( + val isInSearchContext: Boolean = false, + val isInGroupLookupContext: Boolean = false, + val lastSearchText: String? = null +) + +class SearchManager( + private val project: Project, + private val tagManager: TagManager +) { + companion object { + private val logger = thisLogger() + } + + fun getDefaultGroups() = listOf( + FilesGroupItem(project, tagManager), + FoldersGroupItem(project, tagManager), + GitGroupItem(project), + PersonasGroupItem(tagManager), + DocsGroupItem(tagManager), + MCPGroupItem(), + WebActionItem(tagManager) + ).filter { it.enabled } + + suspend fun performGlobalSearch(searchText: String): List { + val allGroups = getDefaultGroups().filterNot { it is WebActionItem } + val allResults = mutableListOf() + + allGroups.forEach { group -> + try { + if (group is LookupGroupItem) { + val lookupActionItems = + group.getLookupItems("").filterIsInstance() + 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) + } + + return filterAndSortResults(allResults, searchText) + } + + private fun filterAndSortResults( + results: List, + searchText: String + ): List { + val matcher: MinusculeMatcher = NameUtil.buildMatcher("*$searchText").build() + + return results.mapNotNull { result -> + when (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(PromptTextFieldConstants.MAX_SEARCH_RESULTS) + } + + fun getSearchTextAfterAt(text: String, caretOffset: Int): String? { + val atPos = text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL) + if (atPos == -1 || atPos >= caretOffset) return null + + val searchText = text.substring(atPos + 1, caretOffset) + return if (searchText.contains(PromptTextFieldConstants.SPACE) || + searchText.contains(PromptTextFieldConstants.NEWLINE) + ) { + null + } else { + searchText + } + } + + fun matchesAnyDefaultGroup(searchText: String): Boolean { + return PromptTextFieldConstants.DEFAULT_GROUP_NAMES.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