feat: improve @ lookup file and folder suggestions

This commit is contained in:
Carl-Robert Linnupuu 2026-04-29 20:47:05 +01:00
parent 13c337a6dc
commit 2d84f689ee
32 changed files with 1703 additions and 724 deletions

View file

@ -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)
}
}
}
}

View file

@ -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<FileSearchCandidate>
}
@ -65,7 +72,7 @@ class NativeFileSearchProvider(private val project: Project) : FileSearchProvide
val matches = mutableListOf<FileSearchCandidate>()
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<FoundItemDescriptor<*>> { 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<FoundItemDescriptor<*>> { 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<Any> { item ->
Processor { item ->
ProgressManager.checkCanceled()
val file = item.toVirtualFile() ?: return@Processor true
appendCandidate(file)

View file

@ -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
}

View file

@ -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()

View file

@ -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<LookupElement>,
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<LookupActionItem>,
searchText: String
results: List<LookupItem>,
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<LookupActionItem>
results: List<LookupItem>,
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<LookupItem>,
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<LookupElement>,
parentGroup: LookupGroupItem,
onDynamicUpdate: (String) -> Unit
lookupItems: List<LookupItem>,
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..<caretOffset) {
text.substring(atIndex + 1, caretOffset)
} else {
""
}
}
private fun replaceAtSymbolWithSearch(
private fun createLookupSelectionCleanup(
editor: Editor,
lookupItem: LookupItem
event: LookupEvent
): LookupSelectionCleanup {
val token = AtLookupToken.from(editor)
val item = event.item
val lookupItem = item?.getUserData(LookupItem.KEY)
val lookupStrings = buildSet {
item?.lookupString?.let(::add)
item?.allLookupStrings?.let(::addAll)
lookupItem?.displayName?.let(::add)
}
.filter { it.isNotEmpty() }
.sortedByDescending { it.length }
return LookupSelectionCleanup(token?.startOffset, lookupStrings)
}
private fun removeLookupTextLater(
editor: Editor,
cleanup: LookupSelectionCleanup?,
mode: LookupCleanupMode
) {
val atPos = findAtSymbolPosition(editor)
if (atPos >= 0) {
runUndoTransparentWriteAction {
val actualSearchText = getSearchTextFromEditor(editor)
val endPos = atPos + 1 + actualSearchText.length
editor.document.deleteString(atPos, endPos)
if (shouldInsertDisplayName(lookupItem)) {
insertWithHighlight(editor, atPos, lookupItem.displayName)
}
}
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<LookupActionItem>.toPrioritizedLookupElements(
searchText: String
): Array<LookupElement> {
return mapIndexed { index, result ->
PrioritizedLookupElement.withPriority(
result.createLookupElement(searchText),
resultPriority(result, index, size)
)
}.toTypedArray()
private fun removeLookupStringBeforeCaret(
editor: Editor,
lookupStrings: List<String>
) {
val caretOffset = editor.caretModel.offset
lookupStrings.firstOrNull { lookupString ->
val startOffset = caretOffset - lookupString.length
removeLookupStringAt(editor, startOffset, lookupString)
}
}
fun replaceLookupItems(
lookup: LookupImpl,
results: List<LookupItem>,
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<String>
)
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
}
}

View file

@ -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<LookupActionItem> = 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<LookupActionItem>()
// 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<LookupActionItem> {
val fileGroup = getDefaultGroups().filterIsInstance<FilesGroupItem>().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<LookupActionItem> =
coroutineScope {
val deferredGroups = getDefaultGroups()
.filter { it is FoldersGroupItem || it is GitGroupItem }
.filterIsInstance<GitGroupItem>()
deferredGroups.map { group ->
async {
try {
if (group is LookupGroupItem) {
group.getLookupItems(searchText).filterIsInstance<LookupActionItem>()
} else {
emptyList()
}
group.getLookupItems(searchText)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@ -165,13 +148,36 @@ class SearchManager(
searchText: String
): List<LookupActionItem> {
val seenKeys = mutableSetOf<String>()
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<LookupActionItem> {
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<LookupActionItem>
) {
cachedSearchText = searchText.trim()
cachedSearchResults = results
}
fun clearSearchResultsCache() {
cachedSearchText = ""
cachedSearchResults = emptyList()
}
suspend fun performGlobalSearch(searchText: String): List<LookupActionItem> {
@ -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)
}
}
}

View file

@ -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<ProxyAISettingsService>()
override fun process(message: Message, promptBuilder: StringBuilder) {
if (!settingsService.isVirtualFileVisible(tagDetails.virtualFile)) {
internal fun appendReferencedFilePaths(
project: Project,
message: Message,
virtualFiles: List<VirtualFile>
) {
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<ProxyAISettingsService>()
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<ProxyAISettingsService>()
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<String>) {
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))
}
}

View file

@ -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<JComponent> = 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<EditorColorsManager>().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()

View file

@ -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())

View file

@ -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
}
}

View file

@ -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<LookupElement>() {
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<String> = 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)
}
}

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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
)
}
}
}

View file

@ -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<Pair<LookupItem, Double>>,
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)

View file

@ -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
}
}

View file

@ -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<String> {
val projectRelativePath = project.guessProjectDir()?.let { projectDir ->
VfsUtil.getRelativePath(folder, projectDir)
}
return listOfNotNull(folder.path, projectRelativePath)
}
}

View file

@ -1,3 +0,0 @@
package ee.carlrobert.codegpt.ui.textarea.lookup.action
interface InsertsDisplayNameLookupItem

View file

@ -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<String> {
val projectRelativePath = project.guessProjectDir()?.let { projectDir ->
VfsUtil.getRelativePath(file, projectDir)
}
return listOfNotNull(file.path, projectRelativePath)
}
}

View file

@ -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<FileEditorManager>().openFiles
.filter { openFile ->
settingsService.isVirtualFileVisible(openFile) &&
!openFile.isHiddenFileOrInHiddenDirectory() &&
fileTags.none { it.virtualFile == openFile }
}
.forEach {

View file

@ -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)

View file

@ -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<ProxyAISettingsService>()
@Volatile
private var cachedFolders: List<VirtualFile> = 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<FileActionItem>()
withContext(Dispatchers.Default) {
lookupItems.forEach { actionItem ->
runInEdt {
LookupUtil.addLookupItem(lookup, actionItem, searchText = searchText)
}
}
}
}
override suspend fun getLookupItems(searchText: String): List<LookupActionItem> {
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<ProjectFileIndex>()
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<FileSearchCandidate> {
@ -105,7 +94,7 @@ class FilesGroupItem(
val projectFileIndex = project.service<ProjectFileIndex>()
project.service<FileEditorManager>().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<FolderActionItem> {
val matcher = createMatcher(searchText)
val resultLimit = if (searchText.isEmpty()) {
MAX_DEFAULT_FOLDER_SUGGESTIONS
} else {
MAX_SEARCH_FOLDERS
}
return readAction {
val projectFileIndex = project.service<ProjectFileIndex>()
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<Pair<VirtualFile, Int>> { 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<FileSearchCandidate>.toFileSuggestions(): List<LookupActionItem> {
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<FileSearchCandidate>.toFileSuggestions(searchText: String): List<LookupActionItem> {
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 <T : TagDetails> getExistingTags(
project: Project,
tagClass: Class<T>
): List<T> {
return project.service<ChatToolWindowContentManager>()
.tryFindActiveChatTabPanel()
.map { it.selectedTags }
.orElse(emptyList())
.filterIsInstance(tagClass)
return includeOpenFiles + fileItems
}
@Synchronized
private fun getProjectFolders(projectFileIndex: ProjectFileIndex): List<VirtualFile> {
val structureModCount = VirtualFileManager.VFS_STRUCTURE_MODIFICATIONS.modificationCount
val rootModCount = ProjectRootManager.getInstance(project).modificationCount
if (cachedFolderStructureModCount == structureModCount &&
cachedFolderRootModCount == rootModCount
) {
return cachedFolders
}
val folders = mutableListOf<VirtualFile>()
projectFileIndex.iterateContent { file ->
if (isProjectFolderCandidate(file, projectFileIndex)) {
folders += file
}
true
}
cachedFolders = folders
cachedFolderStructureModCount = structureModCount
cachedFolderRootModCount = rootModCount
return folders
}
}

View file

@ -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<ProxyAISettingsService>()
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<FolderActionItem>()
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<LookupActionItem> {
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<Pair<VirtualFile, Int>> { 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<VirtualFile> {
val folders = mutableListOf<VirtualFile>()
project.service<ProjectFileIndex>().iterateContent { file: VirtualFile ->
if (file.isDirectory && !file.name.startsWith(".")) {
folders.add(file)
}
true
}
return folders
}
}

View file

@ -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<LookupActionItem> {
return withContext(Dispatchers.Default) {
GitUtil.getProjectRepository(project)?.let {

View file

@ -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<String>()
override suspend fun getLookupItems(searchText: String): List<LookupActionItem> {
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)
}
}
}
}
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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()
}
}

View file

@ -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<FolderActionItem>()
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<FolderActionItem>()
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<FolderActionItem>()
.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<FileActionItem>()
.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<FolderActionItem>()
.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`() {

View file

@ -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
}
}
}