From 2d84f689ee9db58df1393d05d33d07200381c436 Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Wed, 29 Apr 2026 20:47:05 +0100 Subject: [PATCH] feat: improve @ lookup file and folder suggestions --- .../codegpt/ui/textarea/AtLookupToken.kt | 43 + .../codegpt/ui/textarea/FileSearchProvider.kt | 27 +- .../codegpt/ui/textarea/PromptTextField.kt | 823 +++++++++++++++--- .../ui/textarea/PromptTextFieldConstants.kt | 7 +- .../PromptTextFieldEventDispatcher.kt | 27 +- .../textarea/PromptTextFieldLookupManager.kt | 427 +++++---- .../codegpt/ui/textarea/SearchManager.kt | 115 +-- .../ui/textarea/TagProcessorFactory.kt | 64 +- .../codegpt/ui/textarea/UserInputPanel.kt | 26 +- .../textarea/header/UserInputHeaderPanel.kt | 79 +- .../ui/textarea/header/tag/TagManager.kt | 20 +- .../ui/textarea/lookup/AbstractLookupItem.kt | 48 +- .../ui/textarea/lookup/LoadingLookupItem.kt | 27 + .../codegpt/ui/textarea/lookup/LookupItem.kt | 9 +- .../lookup/LookupItemPrefixMatcher.kt | 18 + .../ui/textarea/lookup/LookupMatchers.kt | 21 + .../codegpt/ui/textarea/lookup/LookupUtil.kt | 26 +- .../ui/textarea/lookup/StatusLookupItem.kt | 25 + .../lookup/action/FolderActionItem.kt | 9 +- .../action/InsertsDisplayNameLookupItem.kt | 3 - .../lookup/action/files/FileActionItem.kt | 11 +- .../files/IncludeOpenFilesActionItem.kt | 2 + .../lookup/action/git/GitCommitActionItem.kt | 3 +- .../textarea/lookup/group/FilesGroupItem.kt | 187 ++-- .../textarea/lookup/group/FoldersGroupItem.kt | 103 --- .../ui/textarea/lookup/group/GitGroupItem.kt | 24 - .../textarea/lookup/group/HistoryGroupItem.kt | 29 - .../resources/messages/codegpt.properties | 3 +- .../resources/messages/codegpt_zh.properties | 3 +- .../codegpt/ui/textarea/AtLookupTokenTest.kt | 48 + .../IgnoreRulesTagManagerIntegrationTest.kt | 69 +- .../PromptTextFieldLookupItemsTest.kt | 101 +++ 32 files changed, 1703 insertions(+), 724 deletions(-) create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupToken.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LoadingLookupItem.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItemPrefixMatcher.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupMatchers.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/StatusLookupItem.kt delete mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/InsertsDisplayNameLookupItem.kt delete mode 100644 src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt create mode 100644 src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupTokenTest.kt create mode 100644 src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupItemsTest.kt diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupToken.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupToken.kt new file mode 100644 index 00000000..2ac4c887 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupToken.kt @@ -0,0 +1,43 @@ +package ee.carlrobert.codegpt.ui.textarea + +import com.intellij.openapi.editor.Editor + +data class AtLookupToken( + val startOffset: Int, + val endOffset: Int, + val searchText: String +) { + val prefix: String + get() = PromptTextFieldConstants.AT_SYMBOL + searchText + + companion object { + fun from(editor: Editor): AtLookupToken? { + val text = editor.document.text + return from(text, editor.caretModel.offset) + } + + fun from(text: String, caretOffset: Int): AtLookupToken? { + val boundedCaretOffset = caretOffset.coerceIn(0, text.length) + if (boundedCaretOffset == 0) { + return null + } + + val startOffset = text.lastIndexOf( + PromptTextFieldConstants.AT_SYMBOL, + boundedCaretOffset - 1 + ) + if (startOffset == -1 || startOffset >= boundedCaretOffset) { + return null + } + + val searchText = text.substring(startOffset + 1, boundedCaretOffset) + return if (searchText.contains(PromptTextFieldConstants.SPACE) || + searchText.contains(PromptTextFieldConstants.NEWLINE) + ) { + null + } else { + AtLookupToken(startOffset, boundedCaretOffset, searchText) + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/FileSearchProvider.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/FileSearchProvider.kt index 7ad6b5f9..c5fbd068 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/FileSearchProvider.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/FileSearchProvider.kt @@ -1,15 +1,11 @@ package ee.carlrobert.codegpt.ui.textarea -import com.intellij.ide.actions.searcheverywhere.FoundItemDescriptor -import com.intellij.ide.util.gotoByName.ChooseByNameInScopeItemProvider -import com.intellij.ide.util.gotoByName.ChooseByNamePopup -import com.intellij.ide.util.gotoByName.ChooseByNameViewModel -import com.intellij.ide.util.gotoByName.ChooseByNameWeightedItemProvider -import com.intellij.ide.util.gotoByName.GotoFileModel +import com.intellij.ide.util.gotoByName.* import com.intellij.openapi.application.readAction import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VFileProperty import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiFileSystemItem import com.intellij.psi.search.GlobalSearchScope @@ -29,6 +25,17 @@ data class FileSearchCandidate( val source: FileSearchSource ) +internal fun VirtualFile.isHiddenFileOrInHiddenDirectory(): Boolean { + var current: VirtualFile? = this + while (current != null) { + if (current.name.startsWith(".") || current.`is`(VFileProperty.HIDDEN)) { + return true + } + current = current.parent + } + return false +} + interface FileSearchProvider { suspend fun search(searchText: String, limit: Int): List } @@ -65,7 +72,7 @@ class NativeFileSearchProvider(private val project: Project) : FileSearchProvide val matches = mutableListOf() fun appendCandidate(file: VirtualFile): Boolean { - if (file.isDirectory || !seenPaths.add(file.path)) { + if (!seenPaths.add(file.path)) { return true } matches.add( @@ -83,7 +90,7 @@ class NativeFileSearchProvider(private val project: Project) : FileSearchProvide viewModel, parameters, progressIndicator, - Processor> { descriptor -> + Processor { descriptor -> ProgressManager.checkCanceled() val file = descriptor.item.toVirtualFile() ?: return@Processor true appendCandidate(file) @@ -97,7 +104,7 @@ class NativeFileSearchProvider(private val project: Project) : FileSearchProvide normalizedSearchText, false, progressIndicator, - Processor> { descriptor -> + Processor { descriptor -> ProgressManager.checkCanceled() val file = descriptor.item.toVirtualFile() ?: return@Processor true appendCandidate(file) @@ -111,7 +118,7 @@ class NativeFileSearchProvider(private val project: Project) : FileSearchProvide normalizedSearchText, false, progressIndicator, - Processor { item -> + Processor { item -> ProgressManager.checkCanceled() val file = item.toVirtualFile() ?: return@Processor true appendCandidate(file) 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 b64ff9d4..02e34a1c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -1,6 +1,5 @@ package ee.carlrobert.codegpt.ui.textarea -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 @@ -8,8 +7,8 @@ import com.intellij.ide.IdeEventQueue import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.IdeActions -import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadActionBlocking import com.intellij.openapi.application.runUndoTransparentWriteAction import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger @@ -19,6 +18,8 @@ import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.actionSystem.EditorActionHandler import com.intellij.openapi.editor.actionSystem.EditorActionManager +import com.intellij.openapi.editor.actionSystem.TypedAction +import com.intellij.openapi.editor.actionSystem.TypedActionHandler import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.event.DocumentListener @@ -40,9 +41,7 @@ import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.ui.dnd.FileDragAndDrop 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.* import kotlinx.coroutines.* import java.awt.Cursor import java.awt.Dimension @@ -53,6 +52,20 @@ import java.awt.event.MouseMotionAdapter import java.util.* import javax.swing.JComponent import javax.swing.TransferHandler +import kotlin.time.Duration.Companion.milliseconds + +private enum class LookupDisplayMode { + GROUPS, + GROUP_RESULTS, + GLOBAL_SEARCH +} + +private data class PendingLookupSearch( + val generation: Long, + val mode: LookupDisplayMode, + val searchText: String, + val group: LookupGroupItem? = null +) class PromptTextField( private val project: Project, @@ -60,6 +73,7 @@ class PromptTextField( private val onTextChanged: (String) -> Unit, private val onBackSpace: () -> Unit, private val onLookupAdded: (LookupActionItem) -> Unit, + private val onLookupSearchLoadingChanged: (Boolean) -> Unit = {}, private val onSubmit: (String) -> Unit, private val onFilesDropped: (List) -> Unit = {}, featureType: FeatureType? = null, @@ -81,6 +95,18 @@ class PromptTextField( private var showSuggestionsJob: Job? = null private var searchState = SearchState() private var activeLookupGroup: LookupGroupItem? = null + private var lookupMode: LookupDisplayMode? = null + private var lookupSearchText: String? = null + private var lookupSearchLoading = false + private var lookupSearchLoadingGeneration = 0L + private var lookupSearchLoadingShownAt: Long? = null + private var lookupSearchLoadingJob: Job? = null + private var pendingLookupSearch: PendingLookupSearch? = null + private var lastVisibleLookupItems: List = emptyList() + private var lastVisibleLookupItemsSearchText: String? = null + private var lastVisibleLookupItemsMode: LookupDisplayMode? = null + private var lastVisibleLookupItemsGroup: LookupGroupItem? = null + private val globalSearchTextProvider: () -> String = { lookupSearchText.orEmpty() } val dispatcherId: UUID = UUID.randomUUID() var lookup: LookupImpl? = null @@ -90,7 +116,7 @@ class PromptTextField( document.putUserData(PROMPT_FIELD_KEY, this) setPlaceholder(CodeGPTBundle.get("toolwindow.chat.textArea.emptyText")) - installPasteHandler() + installEditorInputHandlers() } override fun onEditorAdded(editor: Editor) { @@ -192,6 +218,7 @@ class PromptTextField( withContext(Dispatchers.Main) { editor?.let { editor -> + hideLookupIfShown() lookup = lookupManager.showGroupLookup( editor = editor, lookupElements = lookupItems, @@ -205,32 +232,108 @@ class PromptTextField( onLookupAdded(codeAnalyzeAction) } ) + lookupMode = LookupDisplayMode.GROUPS + lookupSearchText = null } } } private fun showGlobalSearchResults( results: List, - searchText: String + searchText: String, + isCalculating: Boolean = false, + generation: Long? = null, + showEmptyStatus: Boolean = true ) { editor?.let { editor -> try { - val existingLookup = lookup - val currentSearchText = runReadAction { + if (generation != null && generation != lookupSearchLoadingGeneration) { + return + } + val currentSearchText = runReadActionBlocking { searchManager.getSearchTextAfterAt( editor.document.text, editor.caretModel.offset ) } - if (existingLookup != null && existingLookup.isShown && !existingLookup.isLookupDisposed) { - if (currentSearchText == searchText) { - lookupManager.updateSearchResultsLookup(existingLookup, results) - return + if (currentSearchText != searchText) { + return + } + + val existingLookup = lookup + val canReuseGlobalLookup = lookupMode == LookupDisplayMode.GLOBAL_SEARCH && + existingLookup != null && + existingLookup.isShown && + !existingLookup.isLookupDisposed + + val previousItems = getRelatedVisibleLookupItems( + LookupDisplayMode.GLOBAL_SEARCH, + searchText, + group = null + ) + val lookupItems = toSearchLookupItems( + results, + searchText, + isCalculating, + previousItems, + showEmptyStatus + ) + + if (canReuseGlobalLookup) { + lookupSearchText = searchText + lookupManager.updateSearchResultsLookup( + existingLookup, + lookupItems, + searchText, + isCalculating, + globalSearchTextProvider, + matcherPrefix = getLookupPrefix(editor) + ) + searchManager.cacheSearchResults(searchText, results) + rememberVisibleLookupItems( + LookupDisplayMode.GLOBAL_SEARCH, + searchText, + group = null, + lookupItems + ) + return + } + + if (lookupItems.isEmpty() && existingLookup != null) { + return + } + + run { + val previouslySelectedKey = existingLookup + ?.takeIf { it.isShown && !it.isLookupDisposed } + ?.let(lookupManager::getSelectedLookupItemKey) + hideLookupIfShown() + lookupSearchText = searchText + lookup = lookupManager.showSearchResultsLookup( + editor, + lookupItems, + searchText, + isCalculating, + globalSearchTextProvider, + lookupPrefix = getLookupPrefix(editor) + ) + lookupMode = LookupDisplayMode.GLOBAL_SEARCH + lookup?.let { + lookupManager.restoreSelectedLookupItem( + it, + previouslySelectedKey + ) + addLookupCleanupListener(it) } } - hideLookupIfShown() - lookup = lookupManager.showSearchResultsLookup(editor, results, searchText) + searchManager.cacheSearchResults(searchText, results) + rememberVisibleLookupItems( + LookupDisplayMode.GLOBAL_SEARCH, + searchText, + group = null, + lookupItems + ) } catch (e: Exception) { logger.error("Error showing lookup: $e", e) } @@ -239,6 +342,8 @@ class PromptTextField( private fun handleGroupSelected(group: LookupGroupItem) { activeLookupGroup = group + searchManager.clearSearchResultsCache() + clearVisibleLookupItems() searchState = searchState.copy( isInSearchContext = true, isInGroupLookupContext = true, @@ -259,56 +364,51 @@ class PromptTextField( return } - val lookupElements = suggestions.map { it.createLookupElement(searchText) }.toTypedArray() - withContext(Dispatchers.Main) { - showSuggestionLookup(lookupElements, group) + showSuggestionLookup(suggestions, searchText) } } private fun showSuggestionLookup( - lookupElements: Array, - parentGroup: LookupGroupItem, + lookupItems: List, + searchText: String = "" ) { editor?.let { editor -> searchState = searchState.copy(isInGroupLookupContext = true) lookup = lookupManager.showSuggestionLookup( editor = editor, - lookupElements = lookupElements, - parentGroup = parentGroup, - onDynamicUpdate = { searchText -> - handleDynamicUpdate(parentGroup, lookupElements, searchText) - } + lookupItems = lookupItems, + searchText = searchText, + lookupPrefix = getLookupPrefix(editor), + ) + lookupMode = LookupDisplayMode.GROUP_RESULTS + lookupSearchText = null + rememberVisibleLookupItems( + LookupDisplayMode.GROUP_RESULTS, + searchText, + activeLookupGroup, + lookupItems ) lookup?.addLookupListener(object : LookupListener { override fun lookupCanceled(event: LookupEvent) { searchState = searchState.copy(isInGroupLookupContext = false) + lookupMode = null + lookupSearchText = null + clearVisibleLookupItems() + } + + override fun itemSelected(event: LookupEvent) { + searchState = searchState.copy(isInGroupLookupContext = false) + lookupMode = null + lookupSearchText = null + clearVisibleLookupItems() } }) } } - private fun handleDynamicUpdate( - parentGroup: LookupGroupItem, - lookupElements: Array, - searchText: 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) - } - } - } - } - } - override fun createEditor(): EditorEx { val editorEx = super.createEditor() if (isFieldDisposed) { @@ -333,6 +433,8 @@ class PromptTextField( override fun dispose() { isFieldDisposed = true showSuggestionsJob?.cancel() + stopLookupSearchLoading() + lookupSearchLoadingJob?.cancel() clearPlaceholders() val ed = this.editor mouseClickListener?.let { l -> ed?.contentComponent?.removeMouseListener(l) } @@ -560,6 +662,10 @@ class PromptTextField( } private fun handleAtSymbolTyped() { + activeLookupGroup = null + searchManager.clearSearchResultsCache() + stopLookupSearchLoading() + clearVisibleLookupItems() searchState = searchState.copy( isInSearchContext = true, lastSearchText = "" @@ -573,12 +679,11 @@ class PromptTextField( private fun handleTextChange(text: String, caretOffset: Int) { val searchText = searchManager.getSearchTextAfterAt(text, caretOffset) - when { searchText != null && activeLookupGroup != null -> handleActiveGroupSearch(searchText) searchText != null && searchText.isEmpty() -> handleEmptySearch() !searchText.isNullOrEmpty() -> handleNonEmptySearch(searchText) - searchText == null -> handleNoSearch() + searchText == null && !searchState.isInGroupLookupContext -> handleNoSearch() } } @@ -594,16 +699,51 @@ class PromptTextField( lastSearchText = searchText ) - showSuggestionsJob?.cancel() - showSuggestionsJob = coroutineScope.launch { - if (searchText.isNotEmpty()) { - delay(PromptTextFieldConstants.SEARCH_DELAY_MS) + scheduleGroupLookupRefresh(group, searchText) + } + } + + private fun scheduleGroupLookupRefresh( + group: LookupGroupItem, + searchText: String + ) { + val effectiveSearchText = getEffectiveGroupSearchText(group, searchText) + showSuggestionsJob?.cancel() + val loadingGeneration = if (effectiveSearchText.isNotEmpty()) { + beginLookupSearchLoading( + LookupDisplayMode.GROUP_RESULTS, + effectiveSearchText, + group + ) + } else { + null + } + showSuggestionsJob = coroutineScope.launch { + try { + if (effectiveSearchText.isNotEmpty()) { + delay(PromptTextFieldConstants.SEARCH_DELAY_MS.milliseconds) } - updateLookupWithGroupResults(group, searchText) + updateLookupWithGroupResults(group, effectiveSearchText, loadingGeneration) + } finally { + loadingGeneration?.let(::finishLookupSearchLoading) } } } + private fun getEffectiveGroupSearchText( + group: LookupGroupItem, + searchText: String + ): String { + return if ( + group is DynamicLookupGroupItem && + searchText.length < group.minimumSearchTextLength + ) { + "" + } else { + searchText + } + } + private fun handleEmptySearch() { if (!searchState.isInSearchContext || searchState.lastSearchText != "") { searchState = searchState.copy( @@ -613,6 +753,7 @@ class PromptTextField( ) showSuggestionsJob?.cancel() + stopLookupSearchLoading() showSuggestionsJob = coroutineScope.launch { updateLookupWithGroups() } @@ -630,9 +771,31 @@ class PromptTextField( ) showSuggestionsJob?.cancel() + val loadingGeneration = beginLookupSearchLoading( + LookupDisplayMode.GLOBAL_SEARCH, + searchText + ) showSuggestionsJob = coroutineScope.launch { - delay(PromptTextFieldConstants.SEARCH_DELAY_MS) - updateLookupWithSearchResults(searchText) + try { + val optimisticResults = + searchManager.getOptimisticSearchResults(searchText) + withContext(Dispatchers.Main) { + showGlobalSearchResults( + optimisticResults, + searchText, + isCalculating = isLookupSearchLoadingVisible(loadingGeneration), + generation = loadingGeneration, + showEmptyStatus = false + ) + } + updateLookupWithSearchResults( + searchText, + optimisticResults, + loadingGeneration + ) + } finally { + finishLookupSearchLoading(loadingGeneration) + } } } } @@ -644,20 +807,291 @@ class PromptTextField( searchState = SearchState() activeLookupGroup = null showSuggestionsJob?.cancel() + searchManager.clearSearchResultsCache() + stopLookupSearchLoading() hideLookupIfShown() } } + private fun beginLookupSearchLoading( + mode: LookupDisplayMode, + searchText: String, + group: LookupGroupItem? = null + ): Long { + lookupSearchLoadingGeneration += 1 + val generation = lookupSearchLoadingGeneration + val pendingSearch = PendingLookupSearch(generation, mode, searchText, group) + pendingLookupSearch = pendingSearch + lookupSearchLoadingJob?.cancel() + lookupSearchLoadingJob = coroutineScope.launch { + delay(PromptTextFieldConstants.LOOKUP_LOADING_REVEAL_MS.milliseconds) + if (!isCurrentLookupSearch(pendingSearch)) { + return@launch + } + setLookupSearchLoading(true) + refreshLookupLoadingState(pendingSearch, isCalculating = true) + } + return generation + } + + private fun finishLookupSearchLoading(generation: Long) { + if (lookupSearchLoadingGeneration == generation) { + lookupSearchLoadingJob?.cancel() + lookupSearchLoadingJob = coroutineScope.launch { + val shownAt = lookupSearchLoadingShownAt + if (lookupSearchLoading && shownAt != null) { + val elapsedMs = System.currentTimeMillis() - shownAt + val remainingMs = + PromptTextFieldConstants.LOOKUP_LOADING_MIN_VISIBLE_MS - elapsedMs + if (remainingMs > 0) { + delay(remainingMs.milliseconds) + } + } + if (lookupSearchLoadingGeneration == generation) { + val completedSearch = pendingLookupSearch + pendingLookupSearch = null + setLookupSearchLoading(false) + completedSearch?.let { + refreshLookupLoadingState(it, isCalculating = false) + } + } + } + } + } + + private fun stopLookupSearchLoading() { + lookupSearchLoadingGeneration += 1 + lookupSearchLoadingJob?.cancel() + pendingLookupSearch = null + setLookupSearchLoading(false) + } + + private fun setLookupSearchLoading(loading: Boolean) { + if (lookupSearchLoading == loading) { + return + } + lookupSearchLoading = loading + lookupSearchLoadingShownAt = if (loading) System.currentTimeMillis() else null + runInEdt { + if (!isFieldDisposed) { + onLookupSearchLoadingChanged(loading) + } + } + } + + private fun isLookupSearchLoadingVisible(generation: Long?): Boolean { + return generation != null && + lookupSearchLoading && + lookupSearchLoadingGeneration == generation + } + + private fun isCurrentLookupSearch(search: PendingLookupSearch): Boolean { + if (isFieldDisposed || lookupSearchLoadingGeneration != search.generation) { + return false + } + val editor = editor ?: return false + val currentSearchText = runReadActionBlocking { + searchManager.getSearchTextAfterAt( + editor.document.text, + editor.caretModel.offset + ) + } ?: return false + + return when (search.mode) { + LookupDisplayMode.GLOBAL_SEARCH -> + !searchState.isInGroupLookupContext && currentSearchText == search.searchText + + LookupDisplayMode.GROUP_RESULTS -> + activeLookupGroup == search.group && + search.group != null && + getEffectiveGroupSearchText(search.group, currentSearchText) == + search.searchText + + LookupDisplayMode.GROUPS -> false + } + } + + private fun refreshLookupLoadingState( + search: PendingLookupSearch, + isCalculating: Boolean + ) { + if (!isCurrentLookupSearch(search)) { + return + } + when (search.mode) { + LookupDisplayMode.GLOBAL_SEARCH -> { + val cachedResults = if (isCalculating) { + emptyList() + } else { + getRelatedVisibleLookupItems( + LookupDisplayMode.GLOBAL_SEARCH, + search.searchText, + group = null + ).filterIsInstance() + } + showGlobalSearchResults( + cachedResults, + search.searchText, + isCalculating, + search.generation + ) + } + + LookupDisplayMode.GROUP_RESULTS -> showGroupLookupLoadingState(search, isCalculating) + LookupDisplayMode.GROUPS -> Unit + } + } + + private fun showGroupLookupLoadingState( + search: PendingLookupSearch, + isCalculating: Boolean + ) { + val group = search.group ?: return + val editor = editor ?: return + val previousItems = getRelatedVisibleLookupItems( + LookupDisplayMode.GROUP_RESULTS, + search.searchText, + group + ) + val lookupItems = toLookupItemsWithLoading( + previousItems, + search.searchText, + isCalculating + ) + val existingLookup = lookup + if (lookupMode == LookupDisplayMode.GROUP_RESULTS && + existingLookup != null && + existingLookup.isShown && + !existingLookup.isLookupDisposed + ) { + lookupManager.updateSuggestionLookup( + existingLookup, + lookupItems, + search.searchText, + isCalculating, + matcherPrefix = getLookupPrefix(editor) + ) + } else { + showSuggestionLookup(lookupItems, search.searchText) + } + rememberVisibleLookupItems( + LookupDisplayMode.GROUP_RESULTS, + search.searchText, + group, + lookupItems + ) + } + + private fun getRelatedVisibleLookupItems( + mode: LookupDisplayMode, + searchText: String, + group: LookupGroupItem? + ): List { + val cachedSearchText = lastVisibleLookupItemsSearchText ?: return emptyList() + if (lastVisibleLookupItemsMode != mode || lastVisibleLookupItemsGroup != group) { + return emptyList() + } + if (!areRelatedLookupSearches(cachedSearchText, searchText)) { + return emptyList() + } + return lastVisibleLookupItems + } + + private fun rememberVisibleLookupItems( + mode: LookupDisplayMode, + searchText: String, + group: LookupGroupItem?, + lookupItems: List + ) { + val reusableItems = lookupItems + .filterNot { it is LoadingLookupItem || it is StatusLookupItem } + .take(PromptTextFieldConstants.MAX_SEARCH_RESULTS) + if (reusableItems.isEmpty()) { + if (!lookupSearchLoading) { + clearVisibleLookupItems() + } + return + } + lastVisibleLookupItems = reusableItems + lastVisibleLookupItemsSearchText = searchText + lastVisibleLookupItemsMode = mode + lastVisibleLookupItemsGroup = group + } + + private fun clearVisibleLookupItems() { + lastVisibleLookupItems = emptyList() + lastVisibleLookupItemsSearchText = null + lastVisibleLookupItemsMode = null + lastVisibleLookupItemsGroup = null + } + + private fun areRelatedLookupSearches( + previousSearchText: String, + currentSearchText: String + ): Boolean { + return currentSearchText.startsWith(previousSearchText, ignoreCase = true) || + previousSearchText.startsWith(currentSearchText, ignoreCase = true) + } + + private fun addLookupCleanupListener(lookup: LookupImpl) { + lookup.addLookupListener(object : LookupListener { + override fun lookupCanceled(event: LookupEvent) { + if (lookupMode == LookupDisplayMode.GLOBAL_SEARCH) { + clearVisibleLookupItems() + lookupMode = null + lookupSearchText = null + } + } + + override fun itemSelected(event: LookupEvent) { + clearVisibleLookupItems() + lookupMode = null + lookupSearchText = null + } + }) + } + private fun hideLookupIfShown() { lookup?.let { existingLookup -> if (!existingLookup.isLookupDisposed && existingLookup.isShown) { runInEdt { existingLookup.hide() } } } + lookupMode = null + lookupSearchText = null + clearVisibleLookupItems() + } + + private fun runGuardedLookupDocumentChange( + documentChange: () -> Unit + ) { + val currentLookup = lookup + if (currentLookup != null && + currentLookup.isShown && + !currentLookup.isLookupDisposed && + isCaretInsideAtLookupToken(currentLookup.editor) + ) { + currentLookup.performGuardedChange { + documentChange() + } + return + } + + documentChange() + } + + private fun isCaretInsideAtLookupToken(editor: Editor): Boolean { + return AtLookupToken.from(editor) != null + } + + private fun getLookupPrefix(editor: Editor): String { + return AtLookupToken.from(editor)?.prefix.orEmpty() } private suspend fun updateLookupWithGroups() { activeLookupGroup = null + searchManager.clearSearchResultsCache() + clearVisibleLookupItems() val lookupItems = searchManager.getDefaultGroups() .map { it.createLookupElement() } .toTypedArray() @@ -675,54 +1109,116 @@ class PromptTextField( lookupElements = lookupItems, onGroupSelected = { group -> handleGroupSelected(group) }, onWebActionSelected = { webAction -> onLookupAdded(webAction) }, - onCodeAnalyzeSelected = { codeAnalyzeAction -> onLookupAdded(codeAnalyzeAction) }, + onCodeAnalyzeSelected = { codeAnalyzeAction -> onLookupAdded(codeAnalyzeAction) } ) + lookupMode = LookupDisplayMode.GROUPS + lookupSearchText = null } } } - private suspend fun updateLookupWithSearchResults(searchText: String) { + private suspend fun updateLookupWithSearchResults( + searchText: String, + optimisticResults: List = emptyList(), + generation: Long? = null + ) { val instantResults = searchManager.performInstantSearch(searchText) - if (instantResults.isNotEmpty()) { - withContext(Dispatchers.Main) { - showGlobalSearchResults(instantResults, searchText) - } + val initialResults = + searchManager.mergeResults(optimisticResults, instantResults, searchText) + withContext(Dispatchers.Main) { + showGlobalSearchResults( + initialResults, + searchText, + isCalculating = isLookupSearchLoadingVisible(generation), + generation = generation, + showEmptyStatus = false + ) } + delay(PromptTextFieldConstants.SEARCH_DELAY_MS.milliseconds) val fileResults = searchManager.performFileSearch(searchText) - val earlyResults = searchManager.mergeResults(fileResults, instantResults, searchText) - if (earlyResults.isNotEmpty()) { - withContext(Dispatchers.Main) { - showGlobalSearchResults(earlyResults, searchText) - } + val earlyResults = searchManager.mergeResults(fileResults, initialResults, searchText) + withContext(Dispatchers.Main) { + showGlobalSearchResults( + earlyResults, + searchText, + isCalculating = isLookupSearchLoadingVisible(generation), + generation = generation, + showEmptyStatus = false + ) } val deferredHeavyResults = searchManager.performDeferredHeavySearch(searchText) - if (deferredHeavyResults.isNotEmpty()) { - val allResults = - searchManager.mergeResults(earlyResults, deferredHeavyResults, searchText) - withContext(Dispatchers.Main) { - showGlobalSearchResults(allResults, searchText) - } + val allResults = + searchManager.mergeResults(earlyResults, deferredHeavyResults, searchText) + withContext(Dispatchers.Main) { + showGlobalSearchResults( + allResults, + searchText, + isCalculating = isLookupSearchLoadingVisible(generation), + generation = generation + ) } } private suspend fun updateLookupWithGroupResults( group: LookupGroupItem, - searchText: String + searchText: String, + generation: Long? = null ) { val suggestions = group.getLookupItems(searchText) - if (suggestions.isEmpty()) { - withContext(Dispatchers.Main) { - hideLookupIfShown() - } - return - } + val previousItems = getRelatedVisibleLookupItems( + LookupDisplayMode.GROUP_RESULTS, + searchText, + group + ) + val lookupItems = toLookupItemsWithLoading( + suggestions, + searchText, + isLookupSearchLoadingVisible(generation), + previousItems + ) - val lookupElements = suggestions.map { it.createLookupElement(searchText) }.toTypedArray() withContext(Dispatchers.Main) { - hideLookupIfShown() - showSuggestionLookup(lookupElements, group) + if (generation != null && generation != lookupSearchLoadingGeneration) { + return@withContext + } + val editor = editor ?: return@withContext + val currentSearchText = runReadActionBlocking { + searchManager.getSearchTextAfterAt( + editor.document.text, + editor.caretModel.offset + ) + } ?: return@withContext + if (activeLookupGroup != group || + getEffectiveGroupSearchText(group, currentSearchText) != searchText + ) { + return@withContext + } + + val existingLookup = lookup + if (lookupMode == LookupDisplayMode.GROUP_RESULTS && + existingLookup != null && + existingLookup.isShown && + !existingLookup.isLookupDisposed + ) { + lookupManager.updateSuggestionLookup( + existingLookup, + lookupItems, + searchText, + isCalculating = isLookupSearchLoadingVisible(generation), + matcherPrefix = getLookupPrefix(editor) + ) + rememberVisibleLookupItems( + LookupDisplayMode.GROUP_RESULTS, + searchText, + group, + lookupItems + ) + return@withContext + } + + showSuggestionLookup(lookupItems, searchText) } } @@ -761,42 +1257,141 @@ class PromptTextField( private val PROMPT_FIELD_KEY: Key = Key.create("codegpt.promptTextField.instance") - private var pasteHandlerInstalled = false - private var originalPasteHandler: EditorActionHandler? = null + private var editorInputHandlersInstalled = false - private fun installPasteHandler() { - if (pasteHandlerInstalled) return - synchronized(PromptTextField::class.java) { - if (pasteHandlerInstalled) return - val manager = EditorActionManager.getInstance() - val existing = manager.getActionHandler(IdeActions.ACTION_EDITOR_PASTE) - originalPasteHandler = existing - manager.setActionHandler( - IdeActions.ACTION_EDITOR_PASTE, - object : EditorActionHandler() { - override fun doExecute( - editor: Editor, - caret: Caret?, - dataContext: DataContext - ) { - val field = editor.document.getUserData(PROMPT_FIELD_KEY) - if (field != null) { - val pasted = try { - CopyPasteManager.getInstance() - .getContents(DataFlavor.stringFlavor) as? String - } catch (_: Exception) { - null - } - if (!pasted.isNullOrEmpty()) { - field.insertPlaceholderFor(pasted) - return - } - } - originalPasteHandler?.execute(editor, caret, dataContext) - } - }) - pasteHandlerInstalled = true + internal fun toSearchLookupItems( + results: List, + searchText: String, + isCalculating: Boolean, + previousVisibleLookupItems: List = emptyList(), + showEmptyStatus: Boolean = true + ): List { + return toLookupItemsWithLoading( + results, + searchText, + isCalculating, + previousVisibleLookupItems, + showEmptyStatus + ) + } + + internal fun toLookupItemsWithLoading( + results: List, + searchText: String, + isCalculating: Boolean, + previousVisibleLookupItems: List = emptyList(), + showEmptyStatus: Boolean = true + ): List { + val baseItems = when { + results.isNotEmpty() -> results + isCalculating -> previousVisibleLookupItems + !showEmptyStatus -> emptyList() + else -> listOf( + StatusLookupItem( + displayName = PromptTextFieldLookupManager.EMPTY_RESULTS_TEXT, + lookupString = searchText + ) + ) } + + return if (isCalculating) { + baseItems.filterNot { it is LoadingLookupItem || it is StatusLookupItem } + + LoadingLookupItem(searchText) + } else { + baseItems + } + } + + private fun installEditorInputHandlers() { + if (editorInputHandlersInstalled) return + synchronized(PromptTextField::class.java) { + if (editorInputHandlersInstalled) return + val manager = EditorActionManager.getInstance() + installTypedHandler() + installGuardedEditorActionHandler(manager, IdeActions.ACTION_EDITOR_BACKSPACE) + installGuardedEditorActionHandler(manager, IdeActions.ACTION_EDITOR_DELETE) + installPasteHandler(manager) + editorInputHandlersInstalled = true + } + } + + private fun installTypedHandler() { + val typedAction = TypedAction.getInstance() + val existing = typedAction.rawHandler + typedAction.setupRawHandler(object : TypedActionHandler { + override fun execute( + editor: Editor, + charTyped: Char, + dataContext: DataContext + ) { + val field = editor.document.getUserData(PROMPT_FIELD_KEY) + if (field == null) { + existing.execute(editor, charTyped, dataContext) + return + } + + field.runGuardedLookupDocumentChange { + existing.execute(editor, charTyped, dataContext) + } + } + }) + } + + private fun installGuardedEditorActionHandler( + manager: EditorActionManager, + actionId: String, + ) { + val existing = manager.getActionHandler(actionId) + manager.setActionHandler( + actionId, + object : EditorActionHandler() { + override fun doExecute( + editor: Editor, + caret: Caret?, + dataContext: DataContext + ) { + val field = editor.document.getUserData(PROMPT_FIELD_KEY) + if (field == null) { + existing.execute(editor, caret, dataContext) + return + } + + field.runGuardedLookupDocumentChange { + existing.execute(editor, caret, dataContext) + } + } + }) + } + + private fun installPasteHandler(manager: EditorActionManager) { + val existing = manager.getActionHandler(IdeActions.ACTION_EDITOR_PASTE) + manager.setActionHandler( + IdeActions.ACTION_EDITOR_PASTE, + object : EditorActionHandler() { + override fun doExecute( + editor: Editor, + caret: Caret?, + dataContext: DataContext + ) { + val field = editor.document.getUserData(PROMPT_FIELD_KEY) + if (field != null) { + val pasted = try { + CopyPasteManager.getInstance() + .getContents(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + if (!pasted.isNullOrEmpty()) { + field.runGuardedLookupDocumentChange { + field.insertPlaceholderFor(pasted) + } + return + } + } + + existing.execute(editor, caret, dataContext) + } + }) } } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt index 6419c258..40a6a88e 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt @@ -2,7 +2,8 @@ package ee.carlrobert.codegpt.ui.textarea object PromptTextFieldConstants { const val SEARCH_DELAY_MS = 200L - const val MIN_DYNAMIC_SEARCH_LENGTH = 2 + const val LOOKUP_LOADING_REVEAL_MS = 120L + const val LOOKUP_LOADING_MIN_VISIBLE_MS = 180L const val MAX_SEARCH_RESULTS = 100 const val MIN_VISIBLE_LINES = 2 const val DEFAULT_TOOL_WINDOW_HEIGHT = 400 @@ -13,7 +14,6 @@ object PromptTextFieldConstants { val DEFAULT_GROUP_NAMES = listOf( "files", "file", "f", - "folders", "folder", "fold", "git", "g", "conversations", "conversation", "conv", "c", "history", "hist", "h", @@ -28,7 +28,4 @@ object PromptTextFieldConstants { const val AT_SYMBOL = "@" const val SPACE = " " const val NEWLINE = "\n" - - const val LIGHT_THEME_COLOR = 0x00627A - const val DARK_THEME_COLOR = 0xCC7832 } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt index d54ca79d..2dbbd0c4 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt @@ -2,24 +2,24 @@ package ee.carlrobert.codegpt.ui.textarea import com.intellij.ide.IdeEventQueue import com.intellij.openapi.application.runUndoTransparentWriteAction +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.util.TextRange import com.intellij.ui.ComponentUtil.findParentByCondition +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import java.awt.AWTEvent import java.awt.Component import java.awt.KeyboardFocusManager +import java.awt.datatransfer.DataFlavor import java.awt.event.InputEvent import java.awt.event.KeyEvent import java.awt.event.MouseEvent import java.util.* -import com.intellij.openapi.ide.CopyPasteManager -import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings -import java.awt.datatransfer.DataFlavor class PromptTextFieldEventDispatcher( private val dispatcherId: UUID, private val onBackSpace: () -> Unit, private val onSubmit: (KeyEvent) -> Unit -) : IdeEventQueue.EventDispatcher { +) : IdeEventQueue.NonLockedEventDispatcher { override fun dispatch(e: AWTEvent): Boolean { if ((e is KeyEvent || e is MouseEvent) && findParent() is PromptTextField) { @@ -41,6 +41,7 @@ class PromptTextFieldEventDispatcher( } } } + KeyEvent.VK_ENTER -> { val settings = ConfigurationSettings.getState().chatCompletionSettings @@ -49,11 +50,15 @@ class PromptTextFieldEventDispatcher( if (anyModifierConfigured) { var expectedModifiers = 0 - if (settings.sendWithCtrlEnter) expectedModifiers = expectedModifiers or InputEvent.CTRL_DOWN_MASK - if (settings.sendWithAltEnter) expectedModifiers = expectedModifiers or InputEvent.ALT_DOWN_MASK - if (settings.sendWithShiftEnter) expectedModifiers = expectedModifiers or InputEvent.SHIFT_DOWN_MASK + if (settings.sendWithCtrlEnter) expectedModifiers = + expectedModifiers or InputEvent.CTRL_DOWN_MASK + if (settings.sendWithAltEnter) expectedModifiers = + expectedModifiers or InputEvent.ALT_DOWN_MASK + if (settings.sendWithShiftEnter) expectedModifiers = + expectedModifiers or InputEvent.SHIFT_DOWN_MASK - val eventModifiers = e.modifiersEx and (InputEvent.CTRL_DOWN_MASK or InputEvent.ALT_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK) + val eventModifiers = + e.modifiersEx and (InputEvent.CTRL_DOWN_MASK or InputEvent.ALT_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK) if (eventModifiers == expectedModifiers) { onSubmit(e) @@ -151,7 +156,11 @@ class PromptTextFieldEventDispatcher( private fun handlePaste(e: KeyEvent): Boolean { val parent = findParent() if (parent is PromptTextField) { - val clipText: String? = try { CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor) as? String } catch (_: Exception) { null } + val clipText: String? = try { + CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } if (clipText.isNullOrEmpty()) return false parent.insertPlaceholderFor(clipText) e.consume() diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt index 30c0188c..286997c3 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupManager.kt @@ -1,54 +1,40 @@ package ee.carlrobert.codegpt.ui.textarea -import com.intellij.codeInsight.completion.PrioritizedLookupElement 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.ApplicationManager +import com.intellij.openapi.application.runReadActionBlocking 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.* import ee.carlrobert.codegpt.ui.textarea.lookup.LookupItem import ee.carlrobert.codegpt.ui.textarea.lookup.LookupUtil import ee.carlrobert.codegpt.ui.textarea.lookup.action.CodeAnalyzeActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.InsertsDisplayNameLookupItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem class PromptTextFieldLookupManager( private val project: Project, - private val onLookupAdded: (LookupActionItem) -> Unit + private val onLookupAdded: (LookupActionItem) -> Unit, ) { + companion object { + const val EMPTY_RESULTS_TEXT = "No results" + } + fun createLookup( editor: Editor, lookupElements: Array, - searchText: String - ): LookupImpl = runReadAction { - val lookup = LookupManager.getInstance(project).createLookup( + searchText: String, + ): LookupImpl = runReadActionBlocking { + LookupManager.getInstance(project).createLookup( editor, lookupElements, searchText, LookupArranger.DefaultArranger() ) as LookupImpl - - lookup.addLookupListener(object : LookupListener { - override fun itemSelected(event: LookupEvent) { - val suggestion = - event.item?.getUserData(LookupItem.KEY) as? LookupActionItem ?: return - - replaceAtSymbolWithSearch(editor, suggestion) - onLookupAdded(suggestion) - } - }) - - lookup } fun showGroupLookup( @@ -59,22 +45,45 @@ class PromptTextFieldLookupManager( onCodeAnalyzeSelected: (CodeAnalyzeActionItem) -> Unit, ): LookupImpl { val lookup = createLookup(editor, lookupElements, "") - lookup.addLookupListener(object : LookupListener { + private var pendingCleanup: LookupSelectionCleanup? = null + + override fun beforeItemSelected(event: LookupEvent): Boolean { + pendingCleanup = createLookupSelectionCleanup(editor, event) + return true + } + override fun itemSelected(event: LookupEvent) { val suggestion = event.item?.getUserData(LookupItem.KEY) ?: return - - replaceAtSymbol(editor, suggestion) + val cleanup = pendingCleanup ?: createLookupSelectionCleanup(editor, event) when (suggestion) { - is WebActionItem -> onWebActionSelected(suggestion) - is CodeAnalyzeActionItem -> onCodeAnalyzeSelected(suggestion) - is LookupGroupItem -> onGroupSelected(suggestion) - is LookupActionItem -> onLookupAdded(suggestion) + is WebActionItem -> { + removeLookupText(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) + onWebActionSelected(suggestion) + removeLookupTextLater(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) + } + + is CodeAnalyzeActionItem -> { + removeLookupText(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) + onCodeAnalyzeSelected(suggestion) + removeLookupTextLater(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) + } + + is LookupGroupItem -> { + removeLookupText(editor, cleanup, LookupCleanupMode.KEEP_AT_SYMBOL) + onGroupSelected(suggestion) // suppress stays active until suggestion lookup opens + removeLookupTextLater(editor, cleanup, LookupCleanupMode.KEEP_AT_SYMBOL) + } + + is LookupActionItem -> { + removeLookupText(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) + onLookupAdded(suggestion) + removeLookupTextLater(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) + } } } }) - lookup.refreshUi(false, true) lookup.showLookup() return lookup @@ -82,163 +91,271 @@ class PromptTextFieldLookupManager( fun showSearchResultsLookup( editor: Editor, - results: List, - searchText: String + results: List, + searchText: String, + isCalculating: Boolean = false, + searchTextProvider: (() -> String)? = null, + lookupPrefix: String = searchText ): LookupImpl { - val lookupElements = results.toPrioritizedLookupElements(searchText) - val lookup = createLookup(editor, lookupElements, "") - lookup.refreshUi(false, true) + val lookup = createLookup(editor, emptyArray(), lookupPrefix) + updateSearchResultsLookup( + lookup, + results, + searchText, + isCalculating, + searchTextProvider, + matcherPrefix = lookupPrefix + ) + addFinalActionSelectionListener(lookup, editor) lookup.showLookup() return lookup } fun updateSearchResultsLookup( lookup: LookupImpl, - results: List + results: List, + searchText: String, + isCalculating: Boolean = false, + searchTextProvider: (() -> String)? = null, + matcherPrefix: String = searchText ): Int { - val existingKeys = lookup.items.mapNotNull { element -> - (element.getUserData(LookupItem.KEY) as? LookupActionItem)?.let(::resultKey) - }.toMutableSet() - val newResults = results.filter { result -> existingKeys.add(resultKey(result)) } - if (newResults.isEmpty()) { - lookup.refreshUi(false, true) - return 0 + return replaceLookupItems( + lookup, + results, + searchText, + isCalculating, + searchTextProvider, + matcherPrefix + ) + } + + fun updateSuggestionLookup( + lookup: LookupImpl, + results: List, + searchText: String, + isCalculating: Boolean = false, + searchTextProvider: (() -> String)? = null, + matcherPrefix: String = searchText + ): Int { + return replaceLookupItems( + lookup, + results, + searchText, + isCalculating, + searchTextProvider, + matcherPrefix + ) + } + + fun getSelectedLookupItemKey(lookup: LookupImpl): String? { + val currentItem = lookup.currentItem ?: return null + val lookupItem = currentItem.getUserData(LookupItem.KEY) ?: return null + return resultKey(lookupItem) + } + + fun restoreSelectedLookupItem( + lookup: LookupImpl, + selectedKey: String? + ) { + if (selectedKey == null) { + return } - LookupUtil.addLookupItems( - lookup, - newResults.mapIndexed { index, result -> - result to resultPriority(result, index, newResults.size) - }, - getSearchTextFromLookup(lookup) - ) - return newResults.size + val matchingItem = lookup.items.firstOrNull { element -> + val lookupItem = element.getUserData(LookupItem.KEY) ?: return@firstOrNull false + resultKey(lookupItem) == selectedKey + } ?: return + + lookup.currentItem = matchingItem + lookup.ensureSelectionVisible(false) } fun showSuggestionLookup( editor: Editor, - lookupElements: Array, - parentGroup: LookupGroupItem, - onDynamicUpdate: (String) -> Unit + lookupItems: List, + searchText: String = "", + isCalculating: Boolean = false, + searchTextProvider: (() -> String)? = null, + lookupPrefix: String = searchText, ): LookupImpl { - val lookup = createLookup(editor, lookupElements, "") - if (parentGroup is DynamicLookupGroupItem) { - setupDynamicLookupListener(lookup, onDynamicUpdate) - } - - lookup.refreshUi(false, true) + val lookup = createLookup(editor, emptyArray(), lookupPrefix) + updateSuggestionLookup( + lookup, + lookupItems, + searchText, + isCalculating, + searchTextProvider, + matcherPrefix = lookupPrefix + ) + addFinalActionSelectionListener(lookup, editor) lookup.showLookup() return lookup } - private fun setupDynamicLookupListener( + private fun addFinalActionSelectionListener( lookup: LookupImpl, - onDynamicUpdate: (String) -> Unit + editor: Editor ) { - lookup.addPrefixChangeListener(object : PrefixChangeListener { - override fun afterAppend(c: Char) { - val searchText = getSearchTextFromLookup(lookup) - if (searchText.length >= PromptTextFieldConstants.MIN_DYNAMIC_SEARCH_LENGTH) { - onDynamicUpdate(searchText) - } + lookup.addLookupListener(object : LookupListener { + private var pendingCleanup: LookupSelectionCleanup? = null + + override fun beforeItemSelected(event: LookupEvent): Boolean { + pendingCleanup = createLookupSelectionCleanup(editor, event) + return true } - override fun afterTruncate() { - val searchText = getSearchTextFromLookup(lookup) - if (searchText.isEmpty()) { - onDynamicUpdate("") - } + override fun itemSelected(event: LookupEvent) { + val suggestion = + event.item?.getUserData(LookupItem.KEY) as? LookupActionItem ?: return + val cleanup = pendingCleanup ?: createLookupSelectionCleanup(editor, event) + removeLookupText(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) + onLookupAdded(suggestion) + removeLookupTextLater(editor, cleanup, LookupCleanupMode.REMOVE_TOKEN) } - }, 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 in 0..= 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) - } - } + ApplicationManager.getApplication().invokeLater { + removeLookupText(editor, cleanup, mode) } } - 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 removeLookupText( + editor: Editor, + cleanup: LookupSelectionCleanup?, + mode: LookupCleanupMode + ) { + runUndoTransparentWriteAction { + val token = AtLookupToken.from(editor) + val anchorOffset = token?.startOffset ?: cleanup?.startOffset ?: editor.caretModel.offset + if (token != null) { + when (mode) { + LookupCleanupMode.REMOVE_TOKEN -> { + editor.document.deleteString(token.startOffset, token.endOffset) + editor.caretModel.moveToOffset(token.startOffset) + } + + LookupCleanupMode.KEEP_AT_SYMBOL -> { + editor.document.deleteString(token.startOffset + 1, token.endOffset) + editor.caretModel.moveToOffset(token.startOffset + 1) + } } } + + val labelOffset = when (mode) { + LookupCleanupMode.REMOVE_TOKEN -> anchorOffset + LookupCleanupMode.KEEP_AT_SYMBOL -> (anchorOffset + 1).coerceAtMost(editor.document.textLength) + } + cleanup?.lookupStrings.orEmpty().firstOrNull { + removeLookupStringAt(editor, labelOffset, it) + } ?: removeLookupStringBeforeCaret(editor, cleanup?.lookupStrings.orEmpty()) } } - private fun shouldInsertDisplayName(lookupItem: LookupItem): Boolean { - return lookupItem is FileActionItem - || lookupItem is FolderActionItem - || lookupItem is InsertsDisplayNameLookupItem + private fun removeLookupStringAt( + editor: Editor, + offset: Int, + lookupString: String + ): Boolean { + val document = editor.document + if (offset < 0 || offset + lookupString.length > document.textLength) { + return false + } + + if (document.charsSequence.substring(offset, offset + lookupString.length) != lookupString) { + return false + } + + document.deleteString(offset, offset + lookupString.length) + editor.caretModel.moveToOffset(offset) + return true } - private fun List.toPrioritizedLookupElements( - searchText: String - ): Array { - return mapIndexed { index, result -> - PrioritizedLookupElement.withPriority( - result.createLookupElement(searchText), - resultPriority(result, index, size) - ) - }.toTypedArray() + private fun removeLookupStringBeforeCaret( + editor: Editor, + lookupStrings: List + ) { + val caretOffset = editor.caretModel.offset + lookupStrings.firstOrNull { lookupString -> + val startOffset = caretOffset - lookupString.length + removeLookupStringAt(editor, startOffset, lookupString) + } + } + + fun replaceLookupItems( + lookup: LookupImpl, + results: List, + searchText: String, + isCalculating: Boolean, + searchTextProvider: (() -> String)?, + matcherPrefix: String + ): Int { + if (lookup.isLookupDisposed) { + return 0 + } + + val selectedKey = getSelectedLookupItemKey(lookup) + lookup.arranger = LookupArranger.DefaultArranger() + LookupUtil.addLookupItems( + lookup, + results.mapIndexed { index, result -> + result to resultPriority(result, index, results.size) + }, + searchText, + searchTextProvider, + matcherPrefix + ) + configureLookup(lookup, isCalculating) + restoreSelectedLookupItem(lookup, selectedKey) + return results.size + } + + private fun configureLookup( + lookup: LookupImpl, + isCalculating: Boolean + ) { + lookup.isStartCompletionWhenNothingMatches = true + lookup.dummyItemCount = 0 + lookup.isCalculating = isCalculating + lookup.refreshUi(false, true) } private fun resultPriority( - result: LookupActionItem, + result: LookupItem, index: Int, total: Int ): Double { - val sourcePriority = when (result) { - is FileActionItem -> when (result.source) { - FileSearchSource.NATIVE -> 3_000.0 - FileSearchSource.OPEN -> 2_500.0 - FileSearchSource.RECENT -> 2_000.0 - } - - else -> 1_000.0 + return if (result is StatusLookupItem || result is LoadingLookupItem) { + 0.0 + } else { + (total - index).toDouble() } - return sourcePriority - index.toDouble() / maxOf(total, 1) } - private fun resultKey(result: LookupActionItem): String { + private fun resultKey(result: LookupItem): String { return when (result) { is FileActionItem -> "file:${result.file.path}" is FolderActionItem -> "folder:${result.folder.path}" @@ -246,25 +363,13 @@ class PromptTextFieldLookupManager( } } - 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 data class LookupSelectionCleanup( + val startOffset: Int?, + val lookupStrings: List + ) - private fun findAtSymbolPosition(editor: Editor): Int { - val atPos = editor.document.text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL) - return if (atPos >= 0) atPos else -1 + private enum class LookupCleanupMode { + REMOVE_TOKEN, + KEEP_AT_SYMBOL } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt index 078465e8..b4ea92ea 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchManager.kt @@ -3,14 +3,15 @@ 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.settings.service.FeatureType 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.LookupMatchers +import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.ImageActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.IncludeOpenFilesActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.git.IncludeCurrentChangesActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.group.* import kotlinx.coroutines.CancellationException @@ -29,7 +30,9 @@ class SearchManager( private val featureType: FeatureType? = null, ) { private val fileSearchProvider = NativeFileSearchProvider(project) - private val foldersGroupItem = FoldersGroupItem(project, tagManager) + private val filesGroupItem = FilesGroupItem(project, tagManager, fileSearchProvider) + private var cachedSearchText: String = "" + private var cachedSearchResults: List = emptyList() companion object { private val logger = thisLogger() @@ -42,16 +45,14 @@ class SearchManager( } private fun getInlineEditGroups() = listOfNotNull( - FilesGroupItem(project, tagManager, fileSearchProvider), - foldersGroupItem, + filesGroupItem, if (GitFeatureAvailability.isAvailable) GitGroupItem(project) else null, HistoryGroupItem(), DiagnosticsGroupItem(tagManager) ).filter { it.enabled } private fun getAgentGroups() = listOfNotNull( - FilesGroupItem(project, tagManager, fileSearchProvider), - foldersGroupItem, + filesGroupItem, if (GitFeatureAvailability.isAvailable) GitGroupItem(project) else null, MCPGroupItem(tagManager), DiagnosticsGroupItem(tagManager), @@ -59,8 +60,7 @@ class SearchManager( ).filter { it.enabled } private fun getAllGroups() = listOfNotNull( - FilesGroupItem(project, tagManager, fileSearchProvider), - foldersGroupItem, + filesGroupItem, if (GitFeatureAvailability.isAvailable) GitGroupItem(project) else null, HistoryGroupItem(), PersonasGroupItem(tagManager), @@ -74,17 +74,12 @@ class SearchManager( val groups = getDefaultGroups() val results = mutableListOf() - // Standalone action items that are normally buried inside heavy groups - if (groups.any { it is FilesGroupItem }) { - results.add(IncludeOpenFilesActionItem()) - } if (GitFeatureAvailability.isAvailable && groups.any { it is GitGroupItem }) { results.add(IncludeCurrentChangesActionItem()) } - // Lightweight groups (in-memory data only) val lightGroups = groups - .filterNot { it is FilesGroupItem || it is FoldersGroupItem || it is GitGroupItem } + .filterNot { it is FilesGroupItem || it is GitGroupItem } .filterNot { it is WebActionItem || it is ImageActionItem } lightGroups.forEach { group -> @@ -114,17 +109,9 @@ class SearchManager( suspend fun performFileSearch(searchText: String): List { val fileGroup = getDefaultGroups().filterIsInstance().firstOrNull() ?: return emptyList() - val matcher = createMatcher(searchText) return try { fileGroup.getLookupItems(searchText) - .filter { result -> - result !is IncludeOpenFilesActionItem || matchesSearchText( - result, - searchText, - matcher - ) - } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -136,16 +123,12 @@ class SearchManager( suspend fun performDeferredHeavySearch(searchText: String): List = coroutineScope { val deferredGroups = getDefaultGroups() - .filter { it is FoldersGroupItem || it is GitGroupItem } + .filterIsInstance() deferredGroups.map { group -> async { try { - if (group is LookupGroupItem) { - group.getLookupItems(searchText).filterIsInstance() - } else { - emptyList() - } + group.getLookupItems(searchText) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -165,13 +148,36 @@ class SearchManager( searchText: String ): List { val seenKeys = mutableSetOf() - val orderedPrimary = primaryResults - .filter { candidate -> seenKeys.add(resultKey(candidate)) } - val orderedSecondary = filterAndSortResults(secondaryResults, searchText) + val combined = (primaryResults + secondaryResults) .filter { candidate -> seenKeys.add(resultKey(candidate)) } - return (orderedPrimary + orderedSecondary) - .take(PromptTextFieldConstants.MAX_SEARCH_RESULTS) + return filterAndSortResults(combined, searchText) + } + + fun getOptimisticSearchResults(searchText: String): List { + val normalizedSearchText = searchText.trim() + val cachedText = cachedSearchText + if (normalizedSearchText.isEmpty() || + cachedText.isEmpty() || + !normalizedSearchText.startsWith(cachedText, ignoreCase = true) + ) { + return emptyList() + } + + return filterAndSortResults(cachedSearchResults, normalizedSearchText) + } + + fun cacheSearchResults( + searchText: String, + results: List + ) { + cachedSearchText = searchText.trim() + cachedSearchResults = results + } + + fun clearSearchResultsCache() { + cachedSearchText = "" + cachedSearchResults = emptyList() } suspend fun performGlobalSearch(searchText: String): List { @@ -200,15 +206,7 @@ class SearchManager( } private fun createMatcher(searchText: String): MinusculeMatcher { - return NameUtil.buildMatcher("*$searchText").build() - } - - private fun matchesSearchText( - result: LookupActionItem, - searchText: String, - matcher: MinusculeMatcher - ): Boolean { - return getMatchingDegree(result, searchText, matcher) != Int.MIN_VALUE + return LookupMatchers.createMatcher(searchText) } private fun getMatchingDegree( @@ -225,6 +223,20 @@ class SearchManager( } } + is FileActionItem -> { + maxOf( + matcher.matchingDegree(result.displayName), + matcher.matchingDegree(result.file.path) + ) + } + + is FolderActionItem -> { + maxOf( + matcher.matchingDegree(result.displayName), + matcher.matchingDegree(result.folder.path) + ) + } + else -> { matcher.matchingDegree(result.displayName) } @@ -233,10 +245,10 @@ class SearchManager( private fun resultKey(result: LookupActionItem): String { return when (result) { - is ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem -> + is FileActionItem -> "file:${result.file.path}" - is ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem -> + is FolderActionItem -> "folder:${result.folder.path}" else -> "${result::class.qualifiedName}:${result.displayName}" @@ -244,17 +256,7 @@ class SearchManager( } 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 - } + return AtLookupToken.from(text, caretOffset)?.searchText } fun matchesAnyDefaultGroup(searchText: String): Boolean { @@ -262,5 +264,4 @@ class SearchManager( groupName.startsWith(searchText, ignoreCase = true) } } - } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt index b8955fbe..526658ee 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt @@ -11,7 +11,7 @@ import ee.carlrobert.codegpt.conversations.Conversation import ee.carlrobert.codegpt.conversations.ConversationsState import ee.carlrobert.codegpt.conversations.message.Message import ee.carlrobert.codegpt.diagnostics.ProjectDiagnosticsService -import ee.carlrobert.codegpt.settings.ProxyAISettingsService +import ee.carlrobert.codegpt.toolwindow.chat.ChatContextSupport import ee.carlrobert.codegpt.ui.textarea.header.tag.* import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem import ee.carlrobert.codegpt.util.EditorUtil @@ -39,39 +39,39 @@ object TagProcessorFactory { is DiagnosticsTagDetails -> DiagnosticsTagProcessor(project, tagDetails) } } -} -class FileTagProcessor( - project: Project, - private val tagDetails: FileTagDetails, -) : TagProcessor { - private val settingsService = project.service() - - override fun process(message: Message, promptBuilder: StringBuilder) { - if (!settingsService.isVirtualFileVisible(tagDetails.virtualFile)) { + internal fun appendReferencedFilePaths( + project: Project, + message: Message, + virtualFiles: List + ) { + val referencedPaths = ChatContextSupport.collectVisibleFiles(project, virtualFiles) + .map(VirtualFile::getPath) + if (referencedPaths.isEmpty()) { return } if (message.referencedFilePaths == null) { message.referencedFilePaths = mutableListOf() } - message.referencedFilePaths?.add(tagDetails.virtualFile.path) + message.referencedFilePaths?.addAll(referencedPaths) + } +} + +class FileTagProcessor( + private val project: Project, + private val tagDetails: FileTagDetails, +) : TagProcessor { + override fun process(message: Message, promptBuilder: StringBuilder) { + TagProcessorFactory.appendReferencedFilePaths(project, message, listOf(tagDetails.virtualFile)) } } class EditorTagProcessor( - project: Project, + private val project: Project, private val tagDetails: EditorTagDetails, ) : TagProcessor { - private val settingsService = project.service() - override fun process(message: Message, promptBuilder: StringBuilder) { - if (!settingsService.isVirtualFileVisible(tagDetails.virtualFile)) { - return - } - if (message.referencedFilePaths == null) { - message.referencedFilePaths = mutableListOf() - } - message.referencedFilePaths?.add(tagDetails.virtualFile.path) + TagProcessorFactory.appendReferencedFilePaths(project, message, listOf(tagDetails.virtualFile)) } } @@ -117,32 +117,14 @@ class PersonaTagProcessor( } class FolderTagProcessor( - project: Project, + private val project: Project, private val tagDetails: FolderTagDetails, ) : TagProcessor { - private val settingsService = project.service() - override fun process( message: Message, promptBuilder: StringBuilder ) { - if (message.referencedFilePaths == null) { - message.referencedFilePaths = mutableListOf() - } - - processFolder(tagDetails.folder, message.referencedFilePaths ?: mutableListOf()) - } - - private fun processFolder(folder: VirtualFile, referencedFilePaths: MutableList) { - folder.children.forEach { child -> - if (!settingsService.isVirtualFileVisible(child)) { - return@forEach - } - when { - child.isDirectory -> processFolder(child, referencedFilePaths) - else -> referencedFilePaths.add(child.path) - } - } + TagProcessorFactory.appendReferencedFilePaths(project, message, listOf(tagDetails.folder)) } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt index e502873f..f91e3386 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -55,6 +55,7 @@ import java.awt.geom.Rectangle2D import java.awt.geom.RoundRectangle2D import java.util.* import javax.swing.JComponent +import javax.swing.JLabel import javax.swing.JPanel class UserInputPanel @JvmOverloads constructor( @@ -104,6 +105,7 @@ class UserInputPanel @JvmOverloads constructor( onTextChanged = ::updateUserTokens, onBackSpace = ::handleBackSpace, onLookupAdded = ::handleLookupAdded, + onLookupSearchLoadingChanged = ::setLookupSearchVisible, onSubmit = ::handleSubmit, onFilesDropped = { files -> includeFiles(files.toMutableList()) @@ -140,8 +142,23 @@ class UserInputPanel @JvmOverloads constructor( InlineEditChips.rejectAll { onRejectAll?.invoke() }.apply { isVisible = false } private var inlineEditControls: List = listOf(acceptChip, rejectChip) + private val lookupSearchIcon = AsyncProcessIcon("lookup-search").apply { + isVisible = false + toolTipText = "Searching..." + } + private val lookupSearchPanel = + JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + add(lookupSearchIcon) + val iconSize = lookupSearchIcon.preferredSize + minimumSize = iconSize + preferredSize = iconSize + maximumSize = iconSize + toolTipText = "Searching..." + isVisible = true + } private val thinkingIcon = AsyncProcessIcon("inline-edit-thinking").apply { isVisible = false } - private val thinkingLabel = javax.swing.JLabel(CodeGPTBundle.get("shared.thinking")).apply { + private val thinkingLabel = JLabel(CodeGPTBundle.get("shared.thinking")).apply { foreground = service().globalScheme.defaultForeground isVisible = false } @@ -635,6 +652,7 @@ class UserInputPanel @JvmOverloads constructor( if (agentTokenCounterPanel != null) { cell(agentTokenCounterPanel).gap(RightGap.SMALL) } + cell(lookupSearchPanel).gap(RightGap.SMALL) cell(thinkingPanel).gap(RightGap.SMALL) cell(acceptChip).gap(RightGap.SMALL) cell(rejectChip).gap(RightGap.SMALL) @@ -668,6 +686,12 @@ class UserInputPanel @JvmOverloads constructor( return pnl } + private fun setLookupSearchVisible(visible: Boolean) { + lookupSearchIcon.isVisible = visible + revalidate() + repaint() + } + fun setInlineEditControlsVisible(visible: Boolean) { inlineEditControls.forEach { it.isVisible = visible } revalidate() diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt index 1952aef6..84234a09 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt @@ -21,10 +21,10 @@ import com.intellij.util.IconUtil import com.intellij.util.ui.JBUI import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.EditorNotifier -import ee.carlrobert.codegpt.EncodingManager import ee.carlrobert.codegpt.settings.ProxyAISettingsService import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.toolwindow.chat.ChatContextSupport import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel import ee.carlrobert.codegpt.ui.IconActionButton import ee.carlrobert.codegpt.ui.WrapLayout @@ -35,7 +35,6 @@ import ee.carlrobert.codegpt.ui.textarea.TagDetailsComparator import ee.carlrobert.codegpt.ui.textarea.header.tag.* import ee.carlrobert.codegpt.util.EditorUtil import ee.carlrobert.codegpt.util.EditorUtil.getSelectedEditor -import ee.carlrobert.codegpt.util.file.FileUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -178,35 +177,21 @@ class UserInputHeaderPanel( override fun onTagAdded(tag: TagDetails) { onTagsChanged() - when (tag) { - is FileTagDetails -> if (tag.selected) adjustReferencedTotal( - tag.virtualFile, - add = true - ) - - is EditorTagDetails -> if (tag.selected) adjustReferencedTotal( - tag.virtualFile, - add = true - ) - - else -> Unit + if (affectsReferencedFiles(tag)) { + refreshReferencedFilesTotal() } } override fun onTagRemoved(tag: TagDetails) { onTagsChanged() - when (tag) { - is FileTagDetails -> if (tag.selected) adjustReferencedTotal( - tag.virtualFile, - add = false - ) + if (affectsReferencedFiles(tag)) { + refreshReferencedFilesTotal() + } + } - is EditorTagDetails -> if (tag.selected) adjustReferencedTotal( - tag.virtualFile, - add = false - ) - - else -> Unit + override fun onTagUpdated(tag: TagDetails) { + if (affectsReferencedFiles(tag)) { + refreshReferencedFilesTotal() } } @@ -269,19 +254,6 @@ class UserInputHeaderPanel( override fun onSelect(tagDetails: TagDetails) { SwingUtilities.invokeLater { onTagsChanged() - when (tagDetails) { - is FileTagDetails -> adjustReferencedTotal( - tagDetails.virtualFile, - add = isSelected - ) - - is EditorTagDetails -> adjustReferencedTotal( - tagDetails.virtualFile, - add = isSelected - ) - - else -> Unit - } tagManager.notifyTagUpdated(tagDetails) } } @@ -304,20 +276,6 @@ class UserInputHeaderPanel( addInitialTags() } - fun setApplyVisible(visible: Boolean) { - applyChip?.isVisible = visible - revalidate() - repaint() - onLayoutChanged() - } - - fun setApplyEnabled(enabled: Boolean) { - applyChip?.isEnabled = enabled - revalidate() - repaint() - onLayoutChanged() - } - private fun addInitialTags() { if (featureType == FeatureType.AGENT) { return @@ -345,19 +303,22 @@ class UserInputHeaderPanel( } } - private fun adjustReferencedTotal(virtualFile: VirtualFile, add: Boolean) { + private fun refreshReferencedFilesTotal() { backgroundScope.launch { - val encodingManager = EncodingManager.getInstance() - val content = FileUtil.readContent(virtualFile) - val tokens = encodingManager.countTokens(content) + val selectedTags = getSelectedTags() + val referencedFileContents = + ChatContextSupport.getReferencedFiles(project, selectedTags) + .map { it.fileContent } runInEdt { - val current = totalTokensPanel.getTokenDetails().referencedFilesTokens - val next = if (add) current + tokens else (current - tokens).coerceAtLeast(0) - totalTokensPanel.updateReferencedFilesTokens(next) + totalTokensPanel.updateReferencedFilesTokens(referencedFileContents) } } } + private fun affectsReferencedFiles(tag: TagDetails): Boolean { + return tag is FileTagDetails || tag is EditorTagDetails || tag is FolderTagDetails + } + private fun initializeEventListeners() { project.messageBus.connect().apply { subscribe(EditorNotifier.SelectionChange.TOPIC, EditorSelectionChangeListener()) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt index 33db398f..251c4428 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt @@ -26,18 +26,14 @@ class TagManager { .toSet() } - fun containsTag(file: VirtualFile): Boolean = tags.any { - // TODO: refactor - if (it is SelectionTagDetails) { - it.virtualFile == file - } else if (it is FileTagDetails) { - it.virtualFile == file - } else if (it is EditorSelectionTagDetails) { - it.virtualFile == file - } else if (it is EditorTagDetails) { - it.virtualFile == file - } else { - false + fun containsTag(file: VirtualFile): Boolean = tags.any { tag -> + when (tag) { + is SelectionTagDetails -> tag.virtualFile == file + is FileTagDetails -> tag.virtualFile == file + is EditorSelectionTagDetails -> tag.virtualFile == file + is EditorTagDetails -> tag.virtualFile == file + is FolderTagDetails -> tag.folder == file + else -> false } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/AbstractLookupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/AbstractLookupItem.kt index db7f791c..a3360291 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/AbstractLookupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/AbstractLookupItem.kt @@ -1,29 +1,40 @@ package ee.carlrobert.codegpt.ui.textarea.lookup import com.intellij.codeInsight.completion.PrioritizedLookupElement +import com.intellij.codeInsight.completion.InsertionContext import com.intellij.codeInsight.lookup.LookupElement import com.intellij.codeInsight.lookup.LookupElementBuilder import com.intellij.codeInsight.lookup.LookupElementPresentation -import com.intellij.psi.codeStyle.NameUtil import com.intellij.codeInsight.lookup.LookupElementRenderer +import com.intellij.openapi.util.TextRange abstract class AbstractLookupItem : LookupItem { - override fun createLookupElement(searchText: String): LookupElement { - val lookupElement = LookupElementBuilder.create(getLookupString()) + override fun createLookupElement( + searchText: String, + searchTextProvider: (() -> String)? + ): LookupElement { + var builder = LookupElementBuilder.create(getLookupString()) .withPresentableText(displayName) .withIcon(icon) + .withInsertHandler { context, _ -> removeInsertedLookupText(context) } .withRenderer(object : LookupElementRenderer() { override fun renderElement( element: LookupElement, presentation: LookupElementPresentation ) { setPresentation(element, presentation) - emphasizeMatch(presentation, searchText) + emphasizeMatch( + presentation, + searchTextProvider?.invoke() ?: searchText + ) } }) - .apply { - putUserData(LookupItem.KEY, this@AbstractLookupItem) - } + getAdditionalLookupStrings().forEach { lookupString -> + builder = builder.withLookupString(lookupString) + } + val lookupElement = builder.apply { + putUserData(LookupItem.KEY, this@AbstractLookupItem) + } return PrioritizedLookupElement.withPriority(lookupElement, 1.0) } @@ -36,11 +47,28 @@ abstract class AbstractLookupItem : LookupItem { return } - val matcher = NameUtil.buildMatcher("*$normalizedSearchText").build() - if (matcher.matchingFragments(displayName) != null) { - presentation.isItemTextBold = true + val matcher = LookupMatchers.createMatcher(normalizedSearchText) + matcher.match(displayName)?.forEach { fragment -> + presentation.decorateItemTextRange( + TextRange(fragment.startOffset, fragment.endOffset), + LookupElementPresentation.LookupItemDecoration.HIGHLIGHT_MATCHED + ) } } abstract fun getLookupString(): String + + protected open fun getAdditionalLookupStrings(): Collection = emptyList() + + private fun removeInsertedLookupText(context: InsertionContext) { + val startOffset = context.startOffset + val tailOffset = context.tailOffset + val document = context.document + if (startOffset in 0..tailOffset && tailOffset <= document.textLength) { + document.deleteString(startOffset, tailOffset) + context.editor.caretModel.moveToOffset(startOffset) + context.tailOffset = startOffset + } + context.setAddCompletionChar(false) + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LoadingLookupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LoadingLookupItem.kt new file mode 100644 index 00000000..8ae3512d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LoadingLookupItem.kt @@ -0,0 +1,27 @@ +package ee.carlrobert.codegpt.ui.textarea.lookup + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementPresentation +import com.intellij.ui.AnimatedIcon + +class LoadingLookupItem( + private val lookupString: String +) : AbstractLookupItem() { + + override val displayName = "Searching..." + override val icon = AnimatedIcon.Default() + override val enabled = false + + override fun setPresentation( + element: LookupElement, + presentation: LookupElementPresentation + ) { + presentation.itemText = displayName + presentation.icon = icon + presentation.isItemTextBold = false + } + + override fun getLookupString(): String { + return lookupString + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt index 10130e73..8468c7bf 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt @@ -2,7 +2,6 @@ package ee.carlrobert.codegpt.ui.textarea.lookup import com.intellij.codeInsight.lookup.LookupElement import com.intellij.codeInsight.lookup.LookupElementPresentation -import com.intellij.codeInsight.lookup.impl.LookupImpl import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key import ee.carlrobert.codegpt.ui.textarea.UserInputPanel @@ -18,7 +17,10 @@ interface LookupItem { val enabled: Boolean get() = true - fun createLookupElement(searchText: String = ""): LookupElement + fun createLookupElement( + searchText: String = "", + searchTextProvider: (() -> String)? = null + ): LookupElement fun setPresentation(element: LookupElement, presentation: LookupElementPresentation) } @@ -27,7 +29,8 @@ interface LookupGroupItem : LookupItem { } interface DynamicLookupGroupItem : LookupGroupItem { - suspend fun updateLookupList(lookup: LookupImpl, searchText: String) + val minimumSearchTextLength: Int + get() = 1 } interface LookupActionItem : LookupItem { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItemPrefixMatcher.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItemPrefixMatcher.kt new file mode 100644 index 00000000..d6ea12a9 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItemPrefixMatcher.kt @@ -0,0 +1,18 @@ +package ee.carlrobert.codegpt.ui.textarea.lookup + +import com.intellij.codeInsight.completion.PrefixMatcher + +class LookupItemPrefixMatcher(prefix: String) : PrefixMatcher(prefix) { + + override fun prefixMatches(name: String): Boolean { + return LookupMatchers.createMatcher(prefix).matchingDegree(name) != Int.MIN_VALUE + } + + override fun cloneWithPrefix(prefix: String): PrefixMatcher { + return LookupItemPrefixMatcher(prefix) + } + + override fun matchingDegree(string: String): Int { + return LookupMatchers.createMatcher(prefix).matchingDegree(string) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupMatchers.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupMatchers.kt new file mode 100644 index 00000000..51f82d0d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupMatchers.kt @@ -0,0 +1,21 @@ +package ee.carlrobert.codegpt.ui.textarea.lookup + +import com.intellij.psi.codeStyle.MinusculeMatcher +import com.intellij.psi.codeStyle.NameUtil +import com.intellij.util.text.matching.MatchingMode + +object LookupMatchers { + + fun createMatcher(searchText: String): MinusculeMatcher { + val normalizedSearchText = searchText.trim() + return if (normalizedSearchText.isEmpty()) { + NameUtil.buildMatcher("*").build() + } else { + NameUtil.buildMatcherWithFallback( + normalizedSearchText, + "*$normalizedSearchText*", + MatchingMode.IGNORE_CASE + ) + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupUtil.kt index aeffa86f..d46a8fff 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupUtil.kt @@ -5,29 +5,33 @@ import com.intellij.codeInsight.completion.PrioritizedLookupElement import com.intellij.codeInsight.lookup.impl.LookupImpl object LookupUtil { + private fun keepLookupOpenMatcher(prefix: String): PrefixMatcher = + object : PrefixMatcher(prefix) { + override fun prefixMatches(name: String): Boolean = true - fun addLookupItem( - lookup: LookupImpl, - lookupItem: LookupItem, - priority: Double = 5.0, - searchText: String = "" - ) { - addLookupItems(lookup, listOf(lookupItem to priority), searchText) - } + override fun cloneWithPrefix(prefix: String): PrefixMatcher { + return keepLookupOpenMatcher(prefix) + } + + override fun matchingDegree(string: String): Int = 0 + } fun addLookupItems( lookup: LookupImpl, lookupItems: List>, - searchText: String = "" + searchText: String = "", + searchTextProvider: (() -> String)? = null, + matcherPrefix: String = searchText ) { if (!lookup.isLookupDisposed) { + val prefixMatcher = keepLookupOpenMatcher(matcherPrefix) lookupItems.forEach { (lookupItem, priority) -> lookup.addItem( PrioritizedLookupElement.withPriority( - lookupItem.createLookupElement(searchText), + lookupItem.createLookupElement(searchText, searchTextProvider), priority ), - PrefixMatcher.ALWAYS_TRUE + prefixMatcher ) } lookup.refreshUi(true, true) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/StatusLookupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/StatusLookupItem.kt new file mode 100644 index 00000000..0983b663 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/StatusLookupItem.kt @@ -0,0 +1,25 @@ +package ee.carlrobert.codegpt.ui.textarea.lookup + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementPresentation + +class StatusLookupItem( + override val displayName: String, + private val lookupString: String = displayName +) : AbstractLookupItem() { + + override val icon = null + override val enabled = false + + override fun setPresentation( + element: LookupElement, + presentation: LookupElementPresentation + ) { + presentation.itemText = displayName + presentation.isItemTextBold = false + } + + override fun getLookupString(): String { + return lookupString + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/FolderActionItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/FolderActionItem.kt index b718907e..d602dccf 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/FolderActionItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/FolderActionItem.kt @@ -13,7 +13,7 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.FolderTagDetails class FolderActionItem( private val project: Project, val folder: VirtualFile -) : AbstractLookupActionItem(), InsertsDisplayNameLookupItem { +) : AbstractLookupActionItem() { override val displayName = folder.name override val icon = AllIcons.Nodes.Folder @@ -33,4 +33,11 @@ class FolderActionItem( override fun execute(project: Project, userInputPanel: UserInputPanel) { userInputPanel.addTag(FolderTagDetails(folder)) } + + override fun getAdditionalLookupStrings(): Collection { + val projectRelativePath = project.guessProjectDir()?.let { projectDir -> + VfsUtil.getRelativePath(folder, projectDir) + } + return listOfNotNull(folder.path, projectRelativePath) + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/InsertsDisplayNameLookupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/InsertsDisplayNameLookupItem.kt deleted file mode 100644 index 13ee5a50..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/InsertsDisplayNameLookupItem.kt +++ /dev/null @@ -1,3 +0,0 @@ -package ee.carlrobert.codegpt.ui.textarea.lookup.action - -interface InsertsDisplayNameLookupItem diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt index 849126cb..53aa7f4b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt @@ -11,14 +11,12 @@ import ee.carlrobert.codegpt.ui.textarea.UserInputPanel import ee.carlrobert.codegpt.ui.textarea.FileSearchSource import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails import ee.carlrobert.codegpt.ui.textarea.lookup.action.AbstractLookupActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.InsertsDisplayNameLookupItem class FileActionItem( private val project: Project, val file: VirtualFile, val source: FileSearchSource = FileSearchSource.OPEN -) : - AbstractLookupActionItem(), InsertsDisplayNameLookupItem { +) : AbstractLookupActionItem() { override val displayName = file.name override val icon = file.fileType.icon ?: AllIcons.FileTypes.Any_type @@ -38,4 +36,11 @@ class FileActionItem( override fun execute(project: Project, userInputPanel: UserInputPanel) { userInputPanel.addTag(FileTagDetails(file)) } + + override fun getAdditionalLookupStrings(): Collection { + val projectRelativePath = project.guessProjectDir()?.let { projectDir -> + VfsUtil.getRelativePath(file, projectDir) + } + return listOfNotNull(file.path, projectRelativePath) + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/IncludeOpenFilesActionItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/IncludeOpenFilesActionItem.kt index 1fb0bb06..444eeca6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/IncludeOpenFilesActionItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/IncludeOpenFilesActionItem.kt @@ -7,6 +7,7 @@ import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.settings.ProxyAISettingsService import ee.carlrobert.codegpt.ui.textarea.UserInputPanel import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails +import ee.carlrobert.codegpt.ui.textarea.isHiddenFileOrInHiddenDirectory import ee.carlrobert.codegpt.ui.textarea.lookup.action.AbstractLookupActionItem import javax.swing.Icon @@ -21,6 +22,7 @@ class IncludeOpenFilesActionItem : AbstractLookupActionItem() { project.service().openFiles .filter { openFile -> settingsService.isVirtualFileVisible(openFile) && + !openFile.isHiddenFileOrInHiddenDirectory() && fileTags.none { it.virtualFile == openFile } } .forEach { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/git/GitCommitActionItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/git/GitCommitActionItem.kt index 221f15f6..e0402d5e 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/git/GitCommitActionItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/git/GitCommitActionItem.kt @@ -5,12 +5,11 @@ import com.intellij.openapi.project.Project import ee.carlrobert.codegpt.ui.textarea.UserInputPanel import ee.carlrobert.codegpt.ui.textarea.header.tag.GitCommitTagDetails import ee.carlrobert.codegpt.ui.textarea.lookup.action.AbstractLookupActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.action.InsertsDisplayNameLookupItem import git4idea.GitCommit class GitCommitActionItem( private val gitCommit: GitCommit, -) : AbstractLookupActionItem(), InsertsDisplayNameLookupItem { +) : AbstractLookupActionItem() { val description: String = gitCommit.id.asString().take(6) 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 e2e22f95..813504ed 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 @@ -1,34 +1,30 @@ package ee.carlrobert.codegpt.ui.textarea.lookup.group -import com.intellij.codeInsight.lookup.impl.LookupImpl import com.intellij.icons.AllIcons import com.intellij.openapi.application.readAction -import com.intellij.openapi.application.runInEdt import com.intellij.openapi.components.service import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.impl.EditorHistoryManager import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.psi.codeStyle.MinusculeMatcher -import com.intellij.psi.codeStyle.NameUtil import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.settings.ProxyAISettingsService -import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager import ee.carlrobert.codegpt.ui.textarea.FileSearchCandidate import ee.carlrobert.codegpt.ui.textarea.FileSearchProvider import ee.carlrobert.codegpt.ui.textarea.FileSearchSource import ee.carlrobert.codegpt.ui.textarea.NativeFileSearchProvider -import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails +import ee.carlrobert.codegpt.ui.textarea.isHiddenFileOrInHiddenDirectory 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.LookupUtil +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupMatchers +import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.IncludeOpenFilesActionItem -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext class FilesGroupItem( private val project: Project, @@ -36,33 +32,28 @@ class FilesGroupItem( private val fileSearchProvider: FileSearchProvider = NativeFileSearchProvider(project) ) : AbstractLookupGroupItem(), DynamicLookupGroupItem { private val settingsService = project.service() + @Volatile + private var cachedFolders: List = emptyList() + @Volatile + private var cachedFolderStructureModCount: Long = -1L + @Volatile + private var cachedFolderRootModCount: Long = -1L override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.files.displayName") override val icon = AllIcons.FileTypes.Any_type - override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) { - val lookupItems = getLookupItems(searchText) - .filterIsInstance() - - withContext(Dispatchers.Default) { - lookupItems.forEach { actionItem -> - runInEdt { - LookupUtil.addLookupItem(lookup, actionItem, searchText = searchText) - } - } - } - } - override suspend fun getLookupItems(searchText: String): List { val normalizedSearchText = searchText.trim() val openFiles = getOpenFileCandidates(normalizedSearchText) val providerMatches = fileSearchProvider.search(normalizedSearchText, MAX_SEARCH_FILES) - val providerFiles = readAction { + val visibleProviderMatches = readAction { val projectFileIndex = project.service() providerMatches.filter { candidate -> - isVisibleProjectFile(candidate.file, projectFileIndex) + isVisibleProjectItem(candidate.file, projectFileIndex) } } + val providerFiles = visibleProviderMatches.filterNot { it.file.isDirectory } + val folderItems = getFolderSuggestions(normalizedSearchText) val orderedCandidates = if (normalizedSearchText.isEmpty()) { buildDefaultCandidates( @@ -82,21 +73,19 @@ class FilesGroupItem( }.distinctBy { it.file.path } } - return orderedCandidates.toFileSuggestions() + return orderedCandidates.toFileSuggestions(normalizedSearchText) + folderItems } companion object { private const val MAX_SEARCH_FILES = 200 private const val MAX_DEFAULT_FILE_SUGGESTIONS = 25 + private const val MAX_SEARCH_FOLDERS = 200 + private const val MAX_DEFAULT_FOLDER_SUGGESTIONS = 10 private const val MAX_OPEN_FILE_SUGGESTIONS = 15 } private fun createMatcher(searchText: String): MinusculeMatcher { - return if (searchText.isEmpty()) { - NameUtil.buildMatcher("*").build() - } else { - NameUtil.buildMatcher("*$searchText").build() - } + return LookupMatchers.createMatcher(searchText) } private suspend fun getOpenFileCandidates(searchText: String): List { @@ -105,7 +94,7 @@ class FilesGroupItem( val projectFileIndex = project.service() project.service().openFiles .filter { file -> - isVisibleProjectFile(file, projectFileIndex) && matcher.matches(file.name) + matchingDegree(file, projectFileIndex, matcher) != Int.MIN_VALUE } .map { file -> FileSearchCandidate( @@ -124,7 +113,7 @@ class FilesGroupItem( .asReversed() .asSequence() .filter { file -> - isVisibleProjectFile(file, projectFileIndex) && matcher.matches(file.name) + matchingDegree(file, projectFileIndex, matcher) != Int.MIN_VALUE } .take(MAX_SEARCH_FILES) .map { file -> @@ -150,6 +139,44 @@ class FilesGroupItem( return prioritizedOpenFiles + recentBackfill } + private suspend fun getFolderSuggestions(searchText: String): List { + val matcher = createMatcher(searchText) + val resultLimit = if (searchText.isEmpty()) { + MAX_DEFAULT_FOLDER_SUGGESTIONS + } else { + MAX_SEARCH_FOLDERS + } + + return readAction { + val projectFileIndex = project.service() + getProjectFolders(projectFileIndex) + .asSequence() + .filter { folder -> isVisibleProjectFolder(folder, projectFileIndex) } + .mapNotNull { folder -> + val matchingDegree = if (searchText.isEmpty()) { + Int.MAX_VALUE + } else { + maxOf( + matcher.matchingDegree(folder.name), + matcher.matchingDegree(folder.path) + ) + } + if (matchingDegree == Int.MIN_VALUE) { + null + } else { + folder to matchingDegree + } + } + .sortedWith( + compareByDescending> { it.second } + .thenBy { it.first.path } + ) + .take(resultLimit) + .map { FolderActionItem(project, it.first) } + .toList() + } + } + private fun containsTag(file: VirtualFile): Boolean { return tagManager.containsTag(file) } @@ -158,31 +185,91 @@ class FilesGroupItem( file: VirtualFile, projectFileIndex: ProjectFileIndex ): Boolean { - return !file.isDirectory && - projectFileIndex.isInContent(file) && + return isVisibleProjectItem(file, projectFileIndex) && + !file.isDirectory + } + + private fun isVisibleProjectFolder( + file: VirtualFile, + projectFileIndex: ProjectFileIndex + ): Boolean { + return isProjectFolderCandidate(file, projectFileIndex) && settingsService.isVirtualFileVisible(file) && + !file.isHiddenFileOrInHiddenDirectory() && !containsTag(file) } - private fun Iterable.toFileSuggestions(): List { - val selectedFileTags = getExistingTags(project, FileTagDetails::class.java) - val fileItems = filter { candidate -> - selectedFileTags.none { it.virtualFile == candidate.file } - }.map { candidate -> + private fun isVisibleProjectItem( + file: VirtualFile, + projectFileIndex: ProjectFileIndex + ): Boolean { + return file.isValid && + projectFileIndex.isInContent(file) && + settingsService.isVirtualFileVisible(file) && + !file.isHiddenFileOrInHiddenDirectory() && + !containsTag(file) + } + + private fun isProjectFolderCandidate( + file: VirtualFile, + projectFileIndex: ProjectFileIndex + ): Boolean { + return file.isValid && + file.isDirectory && + projectFileIndex.isInContent(file) && + !file.isHiddenFileOrInHiddenDirectory() + } + + private fun matchingDegree( + file: VirtualFile, + projectFileIndex: ProjectFileIndex, + matcher: MinusculeMatcher + ): Int { + if (!isVisibleProjectFile(file, projectFileIndex)) { + return Int.MIN_VALUE + } + + return maxOf( + matcher.matchingDegree(file.name), + matcher.matchingDegree(file.path) + ) + } + + private fun Iterable.toFileSuggestions(searchText: String): List { + val fileItems = map { candidate -> FileActionItem(project, candidate.file, candidate.source) } - return listOf(IncludeOpenFilesActionItem()) + fileItems - } + val includeOpenFiles = if (searchText.isEmpty()) { + listOf(IncludeOpenFilesActionItem()) + } else { + val matcher = createMatcher(searchText) + val item = IncludeOpenFilesActionItem() + if (matcher.matchingDegree(item.displayName) != Int.MIN_VALUE) listOf(item) else emptyList() + } - fun getExistingTags( - project: Project, - tagClass: Class - ): List { - return project.service() - .tryFindActiveChatTabPanel() - .map { it.selectedTags } - .orElse(emptyList()) - .filterIsInstance(tagClass) + return includeOpenFiles + fileItems + } + @Synchronized + private fun getProjectFolders(projectFileIndex: ProjectFileIndex): List { + val structureModCount = VirtualFileManager.VFS_STRUCTURE_MODIFICATIONS.modificationCount + val rootModCount = ProjectRootManager.getInstance(project).modificationCount + if (cachedFolderStructureModCount == structureModCount && + cachedFolderRootModCount == rootModCount + ) { + return cachedFolders + } + + val folders = mutableListOf() + projectFileIndex.iterateContent { file -> + if (isProjectFolderCandidate(file, projectFileIndex)) { + folders += file + } + true + } + cachedFolders = folders + cachedFolderStructureModCount = structureModCount + cachedFolderRootModCount = rootModCount + return folders } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt deleted file mode 100644 index 339073d4..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt +++ /dev/null @@ -1,103 +0,0 @@ -package ee.carlrobert.codegpt.ui.textarea.lookup.group - -import com.intellij.codeInsight.lookup.impl.LookupImpl -import com.intellij.icons.AllIcons -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.components.service -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.settings.ProxyAISettingsService -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.LookupItem -import ee.carlrobert.codegpt.ui.textarea.lookup.LookupUtil -import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class FoldersGroupItem( - private val project: Project, - private val tagManager: TagManager -) : AbstractLookupGroupItem(), DynamicLookupGroupItem { - private val settingsService = project.service() - - override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.folders.displayName") - override val icon = AllIcons.Nodes.Folder - - override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) { - val folderSuggestions = getLookupItems(searchText) - .filterIsInstance() - val existingPaths = lookup.items.mapNotNull { element -> - (element.getUserData(LookupItem.KEY) as? FolderActionItem)?.folder?.path - }.toSet() - val newSuggestions = folderSuggestions - .filterNot { it.folder.path in existingPaths } - - if (newSuggestions.isEmpty()) { - return - } - - withContext(Dispatchers.Default) { - runInEdt { - LookupUtil.addLookupItems( - lookup, - newSuggestions.map { it to 5.0 }, - searchText = searchText - ) - } - } - } - - override suspend fun getLookupItems(searchText: String): List { - val normalizedSearchText = searchText.trim() - val matcher = NameUtil.buildMatcher("*$normalizedSearchText").build() - - return getProjectFolders(project) - .asSequence() - .filter { folder -> - settingsService.isVirtualFileVisible(folder) && - !tagManager.containsTag(folder) - } - .mapNotNull { folder -> - if (normalizedSearchText.isEmpty()) { - folder to Int.MAX_VALUE - } else { - val matchingDegree = maxOf( - matcher.matchingDegree(folder.name), - matcher.matchingDegree(folder.path) - ) - if (matchingDegree == Int.MIN_VALUE) { - null - } else { - folder to matchingDegree - } - } - } - .sortedWith( - compareByDescending> { it.second } - .thenBy { it.first.path } - ) - .take(10) - .map { FolderActionItem(project, it.first) } - .toList() - } - - private suspend fun getProjectFolders(project: Project) = withContext(Dispatchers.IO) { - buildProjectFolders(project) - } - - private fun buildProjectFolders(project: Project): List { - val folders = mutableListOf() - project.service().iterateContent { file: VirtualFile -> - if (file.isDirectory && !file.name.startsWith(".")) { - folders.add(file) - } - true - } - return folders - } -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt index c9e52e82..e1144e66 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt @@ -1,14 +1,10 @@ package ee.carlrobert.codegpt.ui.textarea.lookup.group -import com.intellij.codeInsight.lookup.impl.LookupImpl -import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.Project import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.Icons -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.LookupUtil import ee.carlrobert.codegpt.ui.textarea.lookup.action.git.GitCommitActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.git.IncludeCurrentChangesActionItem import ee.carlrobert.codegpt.util.GitUtil @@ -21,26 +17,6 @@ class GitGroupItem(private val project: Project) : AbstractLookupGroupItem(), Dy override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.git.displayName") override val icon: Icon = Icons.VCS - override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) { - withContext(Dispatchers.Default) { - GitUtil.getProjectRepository(project)?.let { - GitUtil.visitRepositoryCommits(project, it) { commit -> - if (commit.id.asString().contains(searchText, true) - || commit.fullMessage.contains(searchText, true) - ) { - runInEdt { - LookupUtil.addLookupItem( - lookup, - GitCommitActionItem(commit), - searchText = searchText - ) - } - } - } - } - } - } - override suspend fun getLookupItems(searchText: String): List { return withContext(Dispatchers.Default) { GitUtil.getProjectRepository(project)?.let { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/HistoryGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/HistoryGroupItem.kt index 03fa5d08..e07637a5 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/HistoryGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/HistoryGroupItem.kt @@ -1,16 +1,11 @@ package ee.carlrobert.codegpt.ui.textarea.lookup.group -import com.intellij.codeInsight.lookup.impl.LookupImpl import com.intellij.icons.AllIcons -import com.intellij.openapi.application.runInEdt import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.conversations.ConversationsState import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem -import ee.carlrobert.codegpt.ui.textarea.lookup.LookupUtil import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext class HistoryGroupItem : AbstractLookupGroupItem(), DynamicLookupGroupItem { @@ -18,8 +13,6 @@ class HistoryGroupItem : AbstractLookupGroupItem(), DynamicLookupGroupItem { CodeGPTBundle.get("suggestionGroupItem.history.displayName") override val icon = AllIcons.Vcs.History - private val addedItems = mutableSetOf() - override suspend fun getLookupItems(searchText: String): List { return ConversationsState.getInstance().conversations .sortedByDescending { it.updatedOn } @@ -33,26 +26,4 @@ class HistoryGroupItem : AbstractLookupGroupItem(), DynamicLookupGroupItem { } .map { HistoryActionItem(it) } } - - override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) { - val filteredItems = getLookupItems(searchText) - - withContext(Dispatchers.Default) { - if (searchText.isEmpty()) { - addedItems.clear() - } - - filteredItems.forEach { item -> - val itemKey = item.displayName - if (!addedItems.contains(itemKey)) { - addedItems.add(itemKey) - runInEdt { - if (!lookup.isLookupDisposed) { - LookupUtil.addLookupItem(lookup, item, searchText = searchText) - } - } - } - } - } - } } diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index ac6e31b3..173270a6 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -347,8 +347,7 @@ addDocumentation.popup.form.url.comment=Enter the full web address of the docume addDocumentation.popup.form.saveCheckbox.label=Save for future reference userMessagePanel.documentation.title=DOCUMENTATION userMessagePanel.persona.title=PERSONA -suggestionGroupItem.files.displayName=Files -suggestionGroupItem.folders.displayName=Folders +suggestionGroupItem.files.displayName=Files and Folders suggestionGroupItem.personas.displayName=Personas suggestionGroupItem.history.displayName=History suggestionGroupItem.docs.displayName=Docs diff --git a/src/main/resources/messages/codegpt_zh.properties b/src/main/resources/messages/codegpt_zh.properties index 83b5917f..666277ea 100644 --- a/src/main/resources/messages/codegpt_zh.properties +++ b/src/main/resources/messages/codegpt_zh.properties @@ -333,8 +333,7 @@ addDocumentation.popup.form.url.comment=\u8F93\u5165\u6587\u6863\u7684\u5B8C\u65 addDocumentation.popup.form.saveCheckbox.label=\u4FDD\u5B58\u4EE5\u4F9B\u5C06\u6765\u53C2\u8003 userMessagePanel.documentation.title=\u6587\u6863 userMessagePanel.persona.title=\u89D2\u8272 -suggestionGroupItem.files.displayName=\u6587\u4EF6 -suggestionGroupItem.folders.displayName=\u6587\u4EF6\u5939 +suggestionGroupItem.files.displayName=\u6587\u4EF6\u548C\u6587\u4EF6\u5939 suggestionGroupItem.personas.displayName=\u89D2\u8272 suggestionGroupItem.history.displayName=\u5386\u53F2\u8BB0\u5F55 suggestionGroupItem.docs.displayName=\u6587\u6863 diff --git a/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupTokenTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupTokenTest.kt new file mode 100644 index 00000000..b8a6442b --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/AtLookupTokenTest.kt @@ -0,0 +1,48 @@ +package ee.carlrobert.codegpt.ui.textarea + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class AtLookupTokenTest { + + @Test + fun `test resolves lookup token before caret`() { + val text = "before @first middle @second after" + val caretOffset = text.indexOf(" after") + + val token = AtLookupToken.from(text, caretOffset) + + assertThat(token).isEqualTo( + AtLookupToken( + startOffset = text.indexOf("@second"), + endOffset = caretOffset, + searchText = "second" + ) + ) + } + + @Test + fun `test ignores at symbols after caret`() { + val text = "before @first middle @second" + val caretOffset = text.indexOf(" middle") + + val token = AtLookupToken.from(text, caretOffset) + + assertThat(token).isEqualTo( + AtLookupToken( + startOffset = text.indexOf("@first"), + endOffset = caretOffset, + searchText = "first" + ) + ) + } + + @Test + fun `test rejects token with whitespace`() { + val text = "before @not a token" + + val token = AtLookupToken.from(text, text.length) + + assertThat(token).isNull() + } +} diff --git a/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/IgnoreRulesTagManagerIntegrationTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/IgnoreRulesTagManagerIntegrationTest.kt index d5e0536b..8dbbca93 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/IgnoreRulesTagManagerIntegrationTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/IgnoreRulesTagManagerIntegrationTest.kt @@ -10,7 +10,6 @@ import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.IncludeOpenFilesActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.group.FilesGroupItem -import ee.carlrobert.codegpt.ui.textarea.lookup.group.FoldersGroupItem import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import testsupport.IntegrationTest @@ -143,7 +142,7 @@ class IgnoreRulesTagManagerIntegrationTest : IntegrationTest() { val filesGroupItem = FilesGroupItem(project, TagManager()) val searchManager = SearchManager(project, TagManager()) - val fileGroupSuggestions = runBlocking { filesGroupItem.getLookupItems("needle") } + val fileGroupSuggestions = runBlocking { filesGroupItem.getLookupItems("") } val globalSearchResults = runBlocking { searchManager.performGlobalSearch("needle") } assertThat(fileGroupSuggestions.first()).isInstanceOf(IncludeOpenFilesActionItem::class.java) @@ -168,13 +167,39 @@ class IgnoreRulesTagManagerIntegrationTest : IntegrationTest() { .anyMatch { it.endsWith("/app/src/test/Recent.kt") } } - fun `test folders group should not suggest ignored folders`() { + fun `test files group typed search should include matching folders`() { + val matchingFolder = + myFixture.addFileToProject("app/docs/NeedleFolder/Visible.kt", "class Visible") + .virtualFile.parent + val filesGroupItem = FilesGroupItem(project, TagManager()) + + val folderSuggestions = runBlocking { filesGroupItem.getLookupItems("NeedleFolder") } + .filterIsInstance() + + assertThat(folderSuggestions.map { it.folder.path }) + .contains(matchingFolder.path) + } + + fun `test files group typed search should include folders by path`() { + val matchingFolder = + myFixture.addFileToProject("app/docs/NestedFolder/Visible.kt", "class Visible") + .virtualFile.parent + val filesGroupItem = FilesGroupItem(project, TagManager()) + + val folderSuggestions = runBlocking { filesGroupItem.getLookupItems("app/docs/Nested") } + .filterIsInstance() + + assertThat(folderSuggestions.map { it.folder.path }) + .contains(matchingFolder.path) + } + + fun `test files group should not suggest ignored folders`() { myFixture.addFileToProject("app/src/main/Hidden.kt", "class Hidden") myFixture.addFileToProject("app/src/test/Visible.kt", "class Visible") writeSettings(ignoreEntries = listOf("app/src/main/**")) - val foldersGroupItem = FoldersGroupItem(project, TagManager()) + val filesGroupItem = FilesGroupItem(project, TagManager()) - val folderSuggestions = runBlocking { foldersGroupItem.getLookupItems("src") } + val folderSuggestions = runBlocking { filesGroupItem.getLookupItems("src") } .filterIsInstance() .map { it.folder.path } @@ -183,19 +208,39 @@ class IgnoreRulesTagManagerIntegrationTest : IntegrationTest() { .anyMatch { it.endsWith("/app/src/test") } } - fun `test folders group should include folders added after the first lookup`() { - myFixture.addFileToProject("app/src/main/Existing.kt", "class Existing") - val foldersGroupItem = FoldersGroupItem(project, TagManager()) + fun `test files group should not suggest dotfiles or files inside dot folders`() { + val dotFile = myFixture.addFileToProject(".env", "SECRET=value").virtualFile + val dotFolderFile = + myFixture.addFileToProject(".github/workflows/build.yml", "name: build").virtualFile + val visibleFile = + myFixture.addFileToProject("app/src/test/VisibleEnv.kt", "class VisibleEnv") + .virtualFile + openFiles(dotFile, dotFolderFile, visibleFile) + val filesGroupItem = FilesGroupItem(project, TagManager()) - runBlocking { foldersGroupItem.getLookupItems("src") } - myFixture.addFileToProject("app/docs/NewDoc.kt", "class NewDoc") + val fileSuggestions = runBlocking { filesGroupItem.getLookupItems("env") } + .filterIsInstance() + .map { it.file.path } - val folderSuggestions = runBlocking { foldersGroupItem.getLookupItems("docs") } + assertThat(fileSuggestions) + .noneMatch { it.endsWith("/.env") } + .noneMatch { it.contains("/.github/") } + .anyMatch { it.endsWith("/app/src/test/VisibleEnv.kt") } + } + + fun `test files group should not suggest dot folders or their child folders`() { + myFixture.addFileToProject(".github/workflows/build.yml", "name: build") + myFixture.addFileToProject("app/github/Visible.kt", "class Visible") + val filesGroupItem = FilesGroupItem(project, TagManager()) + + val folderSuggestions = runBlocking { filesGroupItem.getLookupItems("github") } .filterIsInstance() .map { it.folder.path } assertThat(folderSuggestions) - .anyMatch { it.endsWith("/app/docs") } + .noneMatch { it.endsWith("/.github") } + .noneMatch { it.contains("/.github/") } + .anyMatch { it.endsWith("/app/github") } } fun `test merge results should keep folders with the same display name`() { diff --git a/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupItemsTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupItemsTest.kt new file mode 100644 index 00000000..a8d250d2 --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldLookupItemsTest.kt @@ -0,0 +1,101 @@ +package ee.carlrobert.codegpt.ui.textarea + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementPresentation +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.ui.textarea.lookup.AbstractLookupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LoadingLookupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem +import ee.carlrobert.codegpt.ui.textarea.lookup.StatusLookupItem +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class PromptTextFieldLookupItemsTest { + + @Test + fun `calculating empty results returns loading row`() { + val items = PromptTextField.toSearchLookupItems( + results = emptyList(), + searchText = "abc", + isCalculating = true + ) + + assertThat(items).hasSize(1) + assertThat(items.single()).isInstanceOf(LoadingLookupItem::class.java) + } + + @Test + fun `done empty results returns no results row`() { + val items = PromptTextField.toSearchLookupItems( + results = emptyList(), + searchText = "abc", + isCalculating = false + ) + + assertThat(items).hasSize(1) + assertThat(items.single()).isInstanceOf(StatusLookupItem::class.java) + assertThat(items.single().displayName) + .isEqualTo(PromptTextFieldLookupManager.EMPTY_RESULTS_TEXT) + } + + @Test + fun `pending empty results before loading reveal returns no rows`() { + val items = PromptTextField.toSearchLookupItems( + results = emptyList(), + searchText = "abc", + isCalculating = false, + showEmptyStatus = false + ) + + assertThat(items).isEmpty() + } + + @Test + fun `calculating real results appends loading row`() { + val result = TestLookupActionItem("abc.kt") + + val items = PromptTextField.toSearchLookupItems( + results = listOf(result), + searchText = "abc", + isCalculating = true + ) + + assertThat(items).containsExactly(result, items.last()) + assertThat(items.last()).isInstanceOf(LoadingLookupItem::class.java) + } + + @Test + fun `calculating empty results keeps previous visible rows with loading row`() { + val previous = TestLookupActionItem("abc.kt") + + val items = PromptTextField.toSearchLookupItems( + results = emptyList(), + searchText = "abcd", + isCalculating = true, + previousVisibleLookupItems = listOf(previous) + ) + + assertThat(items).containsExactly(previous, items.last()) + assertThat(items.last()).isInstanceOf(LoadingLookupItem::class.java) + } + + private class TestLookupActionItem( + override val displayName: String + ) : AbstractLookupItem(), LookupActionItem { + + override val icon = null + + override fun execute(project: Project, userInputPanel: UserInputPanel) = Unit + + override fun setPresentation( + element: LookupElement, + presentation: LookupElementPresentation + ) { + presentation.itemText = displayName + } + + override fun getLookupString(): String { + return displayName + } + } +}