mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-20 09:24:08 +00:00
feat: global context search
This commit is contained in:
parent
c3890870c4
commit
e0dbb030fb
5 changed files with 614 additions and 154 deletions
|
|
@ -1,27 +1,23 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea
|
||||
|
||||
import com.intellij.codeInsight.lookup.*
|
||||
import com.intellij.codeInsight.lookup.LookupElement
|
||||
import com.intellij.codeInsight.lookup.LookupEvent
|
||||
import com.intellij.codeInsight.lookup.LookupListener
|
||||
import com.intellij.codeInsight.lookup.impl.LookupImpl
|
||||
import com.intellij.codeInsight.lookup.impl.PrefixChangeListener
|
||||
import com.intellij.ide.IdeEventQueue
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.application.runUndoTransparentWriteAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.colors.EditorColorsManager
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.editor.markup.HighlighterLayer
|
||||
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
||||
import com.intellij.openapi.editor.markup.TextAttributes
|
||||
import com.intellij.openapi.fileTypes.FileTypes
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT
|
||||
|
|
@ -29,27 +25,30 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
|||
import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.git.GitCommitActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.group.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.Dimension
|
||||
import java.util.*
|
||||
|
||||
class PromptTextField(
|
||||
private val project: Project,
|
||||
private val tagManager: TagManager,
|
||||
tagManager: TagManager,
|
||||
private val onTextChanged: (String) -> Unit,
|
||||
private val onBackSpace: () -> Unit,
|
||||
private val onLookupAdded: (LookupActionItem) -> Unit,
|
||||
private val onSubmit: (String) -> Unit,
|
||||
) : EditorTextField(project, FileTypes.PLAIN_TEXT), Disposable {
|
||||
|
||||
companion object {
|
||||
private val logger = thisLogger()
|
||||
}
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private val lookupManager = PromptTextFieldLookupManager(project, onLookupAdded)
|
||||
private val searchManager = SearchManager(project, tagManager)
|
||||
|
||||
private var showSuggestionsJob: Job? = null
|
||||
private var searchState = SearchState()
|
||||
private var lastSearchResults: List<LookupActionItem>? = null
|
||||
|
||||
val dispatcherId: UUID = UUID.randomUUID()
|
||||
var lookup: LookupImpl? = null
|
||||
|
|
@ -62,14 +61,14 @@ class PromptTextField(
|
|||
|
||||
override fun onEditorAdded(editor: Editor) {
|
||||
IdeEventQueue.getInstance().addDispatcher(
|
||||
PromptTextFieldEventDispatcher(dispatcherId, onBackSpace, lookup) {
|
||||
PromptTextFieldEventDispatcher(dispatcherId, onBackSpace, lookup) { event ->
|
||||
val shown = lookup?.let { it.isShown && !it.isLookupDisposed } == true
|
||||
if (shown) {
|
||||
return@PromptTextFieldEventDispatcher
|
||||
}
|
||||
|
||||
onSubmit(text)
|
||||
it.consume()
|
||||
event.consume()
|
||||
},
|
||||
this
|
||||
)
|
||||
|
|
@ -82,63 +81,48 @@ class PromptTextField(
|
|||
}
|
||||
|
||||
suspend fun showGroupLookup() {
|
||||
val lookupItems = listOf(
|
||||
FilesGroupItem(project, tagManager),
|
||||
FoldersGroupItem(project, tagManager),
|
||||
GitGroupItem(project),
|
||||
PersonasGroupItem(tagManager),
|
||||
DocsGroupItem(tagManager),
|
||||
MCPGroupItem(),
|
||||
WebActionItem(tagManager)
|
||||
)
|
||||
.filter { it.enabled }
|
||||
val lookupItems = searchManager.getDefaultGroups()
|
||||
.map { it.createLookupElement() }
|
||||
.toTypedArray()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
editor?.let {
|
||||
showGroupLookup(it, lookupItems)
|
||||
editor?.let { editor ->
|
||||
lookup = lookupManager.showGroupLookup(
|
||||
editor = editor,
|
||||
lookupElements = lookupItems,
|
||||
onGroupSelected = { group, selectedText ->
|
||||
handleGroupSelected(group, selectedText)
|
||||
},
|
||||
onWebActionSelected = { webAction ->
|
||||
onLookupAdded(webAction)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showGroupLookup(editor: Editor, lookupElements: Array<LookupElement>) {
|
||||
lookup = createLookup(editor, lookupElements, "")
|
||||
lookup?.addLookupListener(object : LookupListener {
|
||||
override fun itemSelected(event: LookupEvent) {
|
||||
val lookupString = event.item?.lookupString ?: return
|
||||
val suggestion =
|
||||
event.item?.getUserData(LookupItem.KEY) ?: return
|
||||
val offset = editor.caretModel.offset
|
||||
val start = offset - lookupString.length
|
||||
if (start >= 0) {
|
||||
runUndoTransparentWriteAction {
|
||||
editor.document.deleteString(start, offset)
|
||||
}
|
||||
}
|
||||
|
||||
if (suggestion is WebActionItem) {
|
||||
onLookupAdded(suggestion)
|
||||
}
|
||||
|
||||
if (suggestion !is LookupGroupItem) return
|
||||
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
showGroupSuggestions(suggestion)
|
||||
}
|
||||
private fun showGlobalSearchResults(
|
||||
results: List<LookupActionItem>,
|
||||
searchText: String
|
||||
) {
|
||||
editor?.let { editor ->
|
||||
try {
|
||||
hideLookupIfShown()
|
||||
lookup = lookupManager.showSearchResultsLookup(editor, results, searchText)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error showing lookup: $e", e)
|
||||
}
|
||||
})
|
||||
lookup?.refreshUi(false, true)
|
||||
lookup?.showLookup()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findAtSymbolPosition(editor: Editor): Int {
|
||||
val atPos = editor.document.text.lastIndexOf('@')
|
||||
return if (atPos >= 0) atPos else -1
|
||||
private fun handleGroupSelected(group: LookupGroupItem, searchText: String) {
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
showGroupSuggestions(group, searchText)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showGroupSuggestions(group: LookupGroupItem) {
|
||||
private suspend fun showGroupSuggestions(group: LookupGroupItem, filterText: String = "") {
|
||||
val suggestions = group.getLookupItems()
|
||||
if (suggestions.isEmpty()) {
|
||||
return
|
||||
|
|
@ -147,99 +131,53 @@ class PromptTextField(
|
|||
val lookupElements = suggestions.map { it.createLookupElement() }.toTypedArray()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
showSuggestionLookup(lookupElements, group)
|
||||
showSuggestionLookup(lookupElements, group, filterText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLookup(
|
||||
editor: Editor,
|
||||
lookupElements: Array<LookupElement>,
|
||||
searchText: String
|
||||
) = runReadAction {
|
||||
LookupManager.getInstance(project).createLookup(
|
||||
editor,
|
||||
lookupElements,
|
||||
searchText,
|
||||
LookupArranger.DefaultArranger()
|
||||
) as LookupImpl
|
||||
}
|
||||
|
||||
private fun showSuggestionLookup(
|
||||
lookupElements: Array<LookupElement>,
|
||||
parentGroup: LookupGroupItem,
|
||||
filterText: String = "",
|
||||
) {
|
||||
editor?.let {
|
||||
lookup = createLookup(it, lookupElements, filterText)
|
||||
editor?.let { editor ->
|
||||
searchState = searchState.copy(isInGroupLookupContext = true)
|
||||
|
||||
lookup = lookupManager.showSuggestionLookup(
|
||||
editor = editor,
|
||||
lookupElements = lookupElements,
|
||||
parentGroup = parentGroup,
|
||||
onDynamicUpdate = { searchText ->
|
||||
handleDynamicUpdate(parentGroup, lookupElements, searchText, filterText)
|
||||
},
|
||||
filterText = filterText
|
||||
)
|
||||
|
||||
lookup?.addLookupListener(object : LookupListener {
|
||||
override fun itemSelected(event: LookupEvent) {
|
||||
val lookupItem = event.item?.getUserData(LookupItem.KEY) ?: return
|
||||
if (lookupItem !is LookupActionItem) return
|
||||
|
||||
replaceAtSymbol(it, lookupItem)
|
||||
onLookupAdded(lookupItem)
|
||||
}
|
||||
|
||||
private fun replaceAtSymbol(editor: Editor, lookupItem: LookupItem) {
|
||||
val offset = editor.caretModel.offset
|
||||
val start = findAtSymbolPosition(editor)
|
||||
if (start >= 0) {
|
||||
runUndoTransparentWriteAction {
|
||||
val shouldInsertDisplayName = lookupItem is FileActionItem
|
||||
|| lookupItem is FolderActionItem
|
||||
|| lookupItem is GitCommitActionItem
|
||||
if (shouldInsertDisplayName) {
|
||||
editor.document.deleteString(start, offset)
|
||||
editor.document.insertString(start, lookupItem.displayName)
|
||||
editor.caretModel.moveToOffset(start + lookupItem.displayName.length)
|
||||
editor.markupModel.addRangeHighlighter(
|
||||
start,
|
||||
start + lookupItem.displayName.length,
|
||||
HighlighterLayer.SELECTION,
|
||||
TextAttributes().apply {
|
||||
foregroundColor = JBColor(0x00627A, 0xCC7832)
|
||||
},
|
||||
HighlighterTargetArea.EXACT_RANGE
|
||||
)
|
||||
} else {
|
||||
editor.document.deleteString(start, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun lookupCanceled(event: LookupEvent) {
|
||||
searchState = searchState.copy(isInGroupLookupContext = false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
lookup?.addPrefixChangeListener(object : PrefixChangeListener {
|
||||
override fun afterAppend(c: Char) {
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
if (parentGroup is DynamicLookupGroupItem) {
|
||||
val searchText = getSearchText()
|
||||
if (searchText.length == 2) {
|
||||
parentGroup.updateLookupList(lookup!!, searchText)
|
||||
}
|
||||
}
|
||||
private fun handleDynamicUpdate(
|
||||
parentGroup: LookupGroupItem,
|
||||
lookupElements: Array<LookupElement>,
|
||||
searchText: String,
|
||||
filterText: String
|
||||
) {
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
if (parentGroup is DynamicLookupGroupItem) {
|
||||
if (searchText.length >= PromptTextFieldConstants.MIN_DYNAMIC_SEARCH_LENGTH) {
|
||||
parentGroup.updateLookupList(lookup!!, searchText)
|
||||
} else if (searchText.isEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showSuggestionLookup(lookupElements, parentGroup, filterText)
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterTruncate() {
|
||||
if (parentGroup is DynamicLookupGroupItem) {
|
||||
val searchText = getSearchText()
|
||||
if (searchText.isEmpty()) {
|
||||
showSuggestionLookup(lookupElements, parentGroup, filterText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSearchText(): String {
|
||||
val text = it.document.text
|
||||
return text.substring(text.lastIndexOf("@") + 1)
|
||||
}
|
||||
|
||||
}, this)
|
||||
|
||||
lookup?.refreshUi(false, true)
|
||||
lookup?.showLookup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,11 +190,17 @@ class PromptTextField(
|
|||
}
|
||||
|
||||
override fun updateBorder(editor: EditorEx) {
|
||||
editor.setBorder(JBUI.Borders.empty(4, 8))
|
||||
editor.setBorder(
|
||||
JBUI.Borders.empty(
|
||||
PromptTextFieldConstants.BORDER_PADDING,
|
||||
PromptTextFieldConstants.BORDER_SIDE_PADDING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
showSuggestionsJob?.cancel()
|
||||
lastSearchResults = null
|
||||
}
|
||||
|
||||
private fun setupDocumentListener(editor: EditorEx) {
|
||||
|
|
@ -264,19 +208,140 @@ class PromptTextField(
|
|||
override fun documentChanged(event: DocumentEvent) {
|
||||
adjustHeight(editor)
|
||||
onTextChanged(event.document.text)
|
||||
|
||||
if ("@" == event.newFragment.toString()) {
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
showGroupLookup()
|
||||
}
|
||||
}
|
||||
handleDocumentChange(event)
|
||||
}
|
||||
}, this)
|
||||
}
|
||||
|
||||
private fun handleDocumentChange(event: DocumentEvent) {
|
||||
val text = event.document.text
|
||||
val caretOffset = event.offset + event.newLength
|
||||
|
||||
when {
|
||||
isAtSymbolTyped(event) -> handleAtSymbolTyped()
|
||||
else -> handleTextChange(text, caretOffset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAtSymbolTyped(event: DocumentEvent): Boolean {
|
||||
return PromptTextFieldConstants.AT_SYMBOL == event.newFragment.toString()
|
||||
}
|
||||
|
||||
private fun handleAtSymbolTyped() {
|
||||
searchState = searchState.copy(
|
||||
isInSearchContext = true,
|
||||
lastSearchText = ""
|
||||
)
|
||||
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
showGroupLookup()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTextChange(text: String, caretOffset: Int) {
|
||||
val searchText = searchManager.getSearchTextAfterAt(text, caretOffset)
|
||||
|
||||
when {
|
||||
searchText != null && searchText.isEmpty() -> handleEmptySearch()
|
||||
!searchText.isNullOrEmpty() -> handleNonEmptySearch(searchText)
|
||||
searchText == null -> handleNoSearch()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEmptySearch() {
|
||||
if (!searchState.isInSearchContext || searchState.lastSearchText != "") {
|
||||
searchState = searchState.copy(
|
||||
isInSearchContext = true,
|
||||
lastSearchText = "",
|
||||
isInGroupLookupContext = false
|
||||
)
|
||||
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
updateLookupWithGroups()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNonEmptySearch(searchText: String) {
|
||||
if (!searchState.isInGroupLookupContext) {
|
||||
if (!searchManager.matchesAnyDefaultGroup(searchText)) {
|
||||
if (!searchState.isInSearchContext || searchState.lastSearchText != searchText) {
|
||||
searchState = searchState.copy(
|
||||
isInSearchContext = true,
|
||||
lastSearchText = searchText
|
||||
)
|
||||
|
||||
showSuggestionsJob?.cancel()
|
||||
showSuggestionsJob = coroutineScope.launch {
|
||||
delay(PromptTextFieldConstants.SEARCH_DELAY_MS)
|
||||
updateLookupWithSearchResults(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNoSearch() {
|
||||
if (searchState.isInSearchContext) {
|
||||
searchState = SearchState()
|
||||
showSuggestionsJob?.cancel()
|
||||
hideLookupIfShown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideLookupIfShown() {
|
||||
lookup?.let { existingLookup ->
|
||||
if (!existingLookup.isLookupDisposed && existingLookup.isShown) {
|
||||
runInEdt { existingLookup.hide() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateLookupWithGroups() {
|
||||
val lookupItems = searchManager.getDefaultGroups()
|
||||
.map { it.createLookupElement() }
|
||||
.toTypedArray()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
editor?.let { editor ->
|
||||
lookup?.let { existingLookup ->
|
||||
if (existingLookup.isShown && !existingLookup.isLookupDisposed) {
|
||||
existingLookup.hide()
|
||||
}
|
||||
}
|
||||
|
||||
lookup = lookupManager.showGroupLookup(
|
||||
editor = editor,
|
||||
lookupElements = lookupItems,
|
||||
onGroupSelected = { group, currentSearchText ->
|
||||
handleGroupSelected(
|
||||
group,
|
||||
currentSearchText
|
||||
)
|
||||
},
|
||||
onWebActionSelected = { webAction -> onLookupAdded(webAction) },
|
||||
searchText = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateLookupWithSearchResults(searchText: String) {
|
||||
val matchedResults = searchManager.performGlobalSearch(searchText)
|
||||
|
||||
if (lastSearchResults != matchedResults) {
|
||||
lastSearchResults = matchedResults
|
||||
withContext(Dispatchers.Main) {
|
||||
showGlobalSearchResults(matchedResults, searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjustHeight(editor: EditorEx) {
|
||||
val contentHeight = editor.contentComponent.preferredSize.height + 8
|
||||
val contentHeight =
|
||||
editor.contentComponent.preferredSize.height + PromptTextFieldConstants.HEIGHT_PADDING
|
||||
val maxHeight = JBUI.scale(getToolWindowHeight() / 2)
|
||||
val newHeight = minOf(contentHeight, maxHeight)
|
||||
|
||||
|
|
@ -289,6 +354,7 @@ class PromptTextField(
|
|||
|
||||
private fun getToolWindowHeight(): Int {
|
||||
return project.service<ToolWindowManager>()
|
||||
.getToolWindow("ProxyAI")?.component?.visibleRect?.height ?: 400
|
||||
.getToolWindow("ProxyAI")?.component?.visibleRect?.height
|
||||
?: PromptTextFieldConstants.DEFAULT_TOOL_WINDOW_HEIGHT
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea
|
||||
|
||||
object PromptTextFieldConstants {
|
||||
const val SEARCH_DELAY_MS = 200L
|
||||
const val MIN_DYNAMIC_SEARCH_LENGTH = 2
|
||||
const val MAX_SEARCH_RESULTS = 100
|
||||
const val DEFAULT_TOOL_WINDOW_HEIGHT = 400
|
||||
const val BORDER_PADDING = 4
|
||||
const val BORDER_SIDE_PADDING = 8
|
||||
const val HEIGHT_PADDING = 8
|
||||
|
||||
val DEFAULT_GROUP_NAMES = listOf(
|
||||
"files", "file", "f",
|
||||
"folders", "folder", "fold",
|
||||
"git", "g",
|
||||
"personas", "persona", "p",
|
||||
"docs", "doc", "d",
|
||||
"mcp", "m",
|
||||
"web", "w"
|
||||
)
|
||||
|
||||
const val AT_SYMBOL = "@"
|
||||
const val SPACE = " "
|
||||
const val NEWLINE = "\n"
|
||||
|
||||
const val LIGHT_THEME_COLOR = 0x00627A
|
||||
const val DARK_THEME_COLOR = 0xCC7832
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea
|
||||
|
||||
import com.intellij.codeInsight.lookup.*
|
||||
import com.intellij.codeInsight.lookup.impl.LookupImpl
|
||||
import com.intellij.codeInsight.lookup.impl.PrefixChangeListener
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.application.runUndoTransparentWriteAction
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.markup.HighlighterLayer
|
||||
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
||||
import com.intellij.openapi.editor.markup.TextAttributes
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.ui.JBColor
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.git.GitCommitActionItem
|
||||
|
||||
class PromptTextFieldLookupManager(
|
||||
private val project: Project,
|
||||
private val onLookupAdded: (LookupActionItem) -> Unit
|
||||
) {
|
||||
|
||||
fun createLookup(
|
||||
editor: Editor,
|
||||
lookupElements: Array<LookupElement>,
|
||||
searchText: String
|
||||
): LookupImpl = runReadAction {
|
||||
LookupManager.getInstance(project).createLookup(
|
||||
editor,
|
||||
lookupElements,
|
||||
searchText,
|
||||
LookupArranger.DefaultArranger()
|
||||
) as LookupImpl
|
||||
}
|
||||
|
||||
fun showGroupLookup(
|
||||
editor: Editor,
|
||||
lookupElements: Array<LookupElement>,
|
||||
onGroupSelected: (group: LookupGroupItem, searchText: String) -> Unit,
|
||||
onWebActionSelected: (WebActionItem) -> Unit,
|
||||
searchText: String = ""
|
||||
): LookupImpl {
|
||||
val lookup = createLookup(editor, lookupElements, "")
|
||||
|
||||
lookup.addLookupListener(object : LookupListener {
|
||||
override fun itemSelected(event: LookupEvent) {
|
||||
val suggestion = event.item?.getUserData(LookupItem.KEY) ?: return
|
||||
|
||||
replaceAtSymbol(editor, suggestion)
|
||||
|
||||
when (suggestion) {
|
||||
is WebActionItem -> onWebActionSelected(suggestion)
|
||||
is LookupGroupItem -> onGroupSelected(suggestion, searchText)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
lookup.refreshUi(false, true)
|
||||
lookup.showLookup()
|
||||
return lookup
|
||||
}
|
||||
|
||||
fun showSearchResultsLookup(
|
||||
editor: Editor,
|
||||
results: List<LookupActionItem>,
|
||||
searchText: String
|
||||
): LookupImpl {
|
||||
val lookupElements = results.map { it.createLookupElement() }.toTypedArray()
|
||||
val lookup = createLookup(editor, lookupElements, "")
|
||||
|
||||
lookup.addLookupListener(object : LookupListener {
|
||||
override fun itemSelected(event: LookupEvent) {
|
||||
val lookupString = event.item?.lookupString ?: return
|
||||
val suggestion =
|
||||
event.item?.getUserData(LookupItem.KEY) as? LookupActionItem ?: return
|
||||
|
||||
val offset = editor.caretModel.offset
|
||||
val start = offset - lookupString.length
|
||||
if (start >= 0) {
|
||||
runUndoTransparentWriteAction {
|
||||
editor.document.deleteString(start, offset)
|
||||
}
|
||||
}
|
||||
|
||||
replaceAtSymbolWithSearch(editor, suggestion, searchText)
|
||||
onLookupAdded(suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
lookup.refreshUi(false, true)
|
||||
lookup.showLookup()
|
||||
return lookup
|
||||
}
|
||||
|
||||
fun showSuggestionLookup(
|
||||
editor: Editor,
|
||||
lookupElements: Array<LookupElement>,
|
||||
parentGroup: LookupGroupItem,
|
||||
onDynamicUpdate: (String) -> Unit,
|
||||
filterText: String = ""
|
||||
): LookupImpl {
|
||||
val lookup = createLookup(editor, lookupElements, filterText)
|
||||
|
||||
lookup.addLookupListener(object : LookupListener {
|
||||
override fun itemSelected(event: LookupEvent) {
|
||||
val lookupString = event.item?.lookupString ?: return
|
||||
val suggestion =
|
||||
event.item?.getUserData(LookupItem.KEY) as? LookupActionItem ?: return
|
||||
|
||||
val offset = editor.caretModel.offset
|
||||
val start = offset - lookupString.length
|
||||
if (start >= 0) {
|
||||
runUndoTransparentWriteAction {
|
||||
editor.document.deleteString(start, offset)
|
||||
}
|
||||
}
|
||||
|
||||
replaceAtSymbolWithSearch(editor, suggestion, filterText)
|
||||
onLookupAdded(suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
if (parentGroup is DynamicLookupGroupItem) {
|
||||
setupDynamicLookupListener(lookup, onDynamicUpdate)
|
||||
}
|
||||
|
||||
lookup.refreshUi(false, true)
|
||||
lookup.showLookup()
|
||||
return lookup
|
||||
}
|
||||
|
||||
private fun setupDynamicLookupListener(
|
||||
lookup: LookupImpl,
|
||||
onDynamicUpdate: (String) -> Unit
|
||||
) {
|
||||
lookup.addPrefixChangeListener(object : PrefixChangeListener {
|
||||
override fun afterAppend(c: Char) {
|
||||
val searchText = getSearchTextFromLookup(lookup)
|
||||
if (searchText.length >= PromptTextFieldConstants.MIN_DYNAMIC_SEARCH_LENGTH) {
|
||||
onDynamicUpdate(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterTruncate() {
|
||||
val searchText = getSearchTextFromLookup(lookup)
|
||||
if (searchText.isEmpty()) {
|
||||
onDynamicUpdate("")
|
||||
}
|
||||
}
|
||||
}, lookup)
|
||||
}
|
||||
|
||||
private fun getSearchTextFromLookup(lookup: LookupImpl): String {
|
||||
val editor = lookup.editor
|
||||
val text = editor.document.text
|
||||
val atIndex = text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL)
|
||||
return if (atIndex >= 0) text.substring(atIndex + 1) else ""
|
||||
}
|
||||
|
||||
private fun getSearchTextFromEditor(editor: Editor): String {
|
||||
val text = editor.document.text
|
||||
val caretOffset = editor.caretModel.offset
|
||||
val atIndex = text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL)
|
||||
return if (atIndex >= 0 && atIndex < caretOffset) {
|
||||
text.substring(atIndex + 1, caretOffset)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceAtSymbolWithSearch(
|
||||
editor: Editor,
|
||||
lookupItem: LookupItem,
|
||||
searchText: String
|
||||
) {
|
||||
val atPos = findAtSymbolPosition(editor)
|
||||
if (atPos >= 0) {
|
||||
runUndoTransparentWriteAction {
|
||||
val actualSearchText = getSearchTextFromEditor(editor)
|
||||
val endPos = atPos + 1 + actualSearchText.length
|
||||
editor.document.deleteString(atPos, endPos)
|
||||
|
||||
if (shouldInsertDisplayName(lookupItem)) {
|
||||
insertWithHighlight(editor, atPos, lookupItem.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceAtSymbol(editor: Editor, lookupItem: LookupItem) {
|
||||
val offset = editor.caretModel.offset
|
||||
val start = findAtSymbolPosition(editor)
|
||||
if (start >= 0) {
|
||||
runUndoTransparentWriteAction {
|
||||
val shouldInsert = shouldInsertDisplayName(lookupItem)
|
||||
if (shouldInsert) {
|
||||
editor.document.deleteString(start, offset)
|
||||
insertWithHighlight(editor, start, lookupItem.displayName)
|
||||
} else {
|
||||
editor.document.deleteString(start + 1, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldInsertDisplayName(lookupItem: LookupItem): Boolean {
|
||||
return lookupItem is FileActionItem
|
||||
|| lookupItem is FolderActionItem
|
||||
|| lookupItem is GitCommitActionItem
|
||||
}
|
||||
|
||||
private fun insertWithHighlight(editor: Editor, position: Int, text: String) {
|
||||
editor.document.insertString(position, text)
|
||||
editor.caretModel.moveToOffset(position + text.length)
|
||||
editor.markupModel.addRangeHighlighter(
|
||||
position,
|
||||
position + text.length,
|
||||
HighlighterLayer.SELECTION,
|
||||
TextAttributes().apply {
|
||||
foregroundColor = JBColor(
|
||||
PromptTextFieldConstants.LIGHT_THEME_COLOR,
|
||||
PromptTextFieldConstants.DARK_THEME_COLOR
|
||||
)
|
||||
},
|
||||
HighlighterTargetArea.EXACT_RANGE
|
||||
)
|
||||
}
|
||||
|
||||
private fun findAtSymbolPosition(editor: Editor): Int {
|
||||
val atPos = editor.document.text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL)
|
||||
return if (atPos >= 0) atPos else -1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea
|
||||
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.codeStyle.MinusculeMatcher
|
||||
import com.intellij.psi.codeStyle.NameUtil
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.group.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
data class SearchState(
|
||||
val isInSearchContext: Boolean = false,
|
||||
val isInGroupLookupContext: Boolean = false,
|
||||
val lastSearchText: String? = null
|
||||
)
|
||||
|
||||
class SearchManager(
|
||||
private val project: Project,
|
||||
private val tagManager: TagManager
|
||||
) {
|
||||
companion object {
|
||||
private val logger = thisLogger()
|
||||
}
|
||||
|
||||
fun getDefaultGroups() = listOf(
|
||||
FilesGroupItem(project, tagManager),
|
||||
FoldersGroupItem(project, tagManager),
|
||||
GitGroupItem(project),
|
||||
PersonasGroupItem(tagManager),
|
||||
DocsGroupItem(tagManager),
|
||||
MCPGroupItem(),
|
||||
WebActionItem(tagManager)
|
||||
).filter { it.enabled }
|
||||
|
||||
suspend fun performGlobalSearch(searchText: String): List<LookupActionItem> {
|
||||
val allGroups = getDefaultGroups().filterNot { it is WebActionItem }
|
||||
val allResults = mutableListOf<LookupActionItem>()
|
||||
|
||||
allGroups.forEach { group ->
|
||||
try {
|
||||
if (group is LookupGroupItem) {
|
||||
val lookupActionItems =
|
||||
group.getLookupItems("").filterIsInstance<LookupActionItem>()
|
||||
allResults.addAll(lookupActionItems)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error getting results from ${group::class.simpleName}", e)
|
||||
}
|
||||
}
|
||||
|
||||
val webAction = WebActionItem(tagManager)
|
||||
if (webAction.enabled()) {
|
||||
allResults.add(webAction)
|
||||
}
|
||||
|
||||
return filterAndSortResults(allResults, searchText)
|
||||
}
|
||||
|
||||
private fun filterAndSortResults(
|
||||
results: List<LookupActionItem>,
|
||||
searchText: String
|
||||
): List<LookupActionItem> {
|
||||
val matcher: MinusculeMatcher = NameUtil.buildMatcher("*$searchText").build()
|
||||
|
||||
return results.mapNotNull { result ->
|
||||
when (result) {
|
||||
is WebActionItem -> {
|
||||
if (searchText.contains("web", ignoreCase = true)) {
|
||||
result to 100
|
||||
} else null
|
||||
}
|
||||
|
||||
else -> {
|
||||
val matchingDegree = matcher.matchingDegree(result.displayName)
|
||||
if (matchingDegree != Int.MIN_VALUE) {
|
||||
result to matchingDegree
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
.sortedByDescending { it.second }
|
||||
.map { it.first }
|
||||
.take(PromptTextFieldConstants.MAX_SEARCH_RESULTS)
|
||||
}
|
||||
|
||||
fun getSearchTextAfterAt(text: String, caretOffset: Int): String? {
|
||||
val atPos = text.lastIndexOf(PromptTextFieldConstants.AT_SYMBOL)
|
||||
if (atPos == -1 || atPos >= caretOffset) return null
|
||||
|
||||
val searchText = text.substring(atPos + 1, caretOffset)
|
||||
return if (searchText.contains(PromptTextFieldConstants.SPACE) ||
|
||||
searchText.contains(PromptTextFieldConstants.NEWLINE)
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
searchText
|
||||
}
|
||||
}
|
||||
|
||||
fun matchesAnyDefaultGroup(searchText: String): Boolean {
|
||||
return PromptTextFieldConstants.DEFAULT_GROUP_NAMES.any { groupName ->
|
||||
groupName.startsWith(searchText, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager
|
|||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.roots.ProjectFileIndex
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.codeStyle.NameUtil
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
||||
|
|
@ -46,9 +47,27 @@ class FilesGroupItem(
|
|||
override suspend fun getLookupItems(searchText: String): List<LookupActionItem> {
|
||||
return readAction {
|
||||
val projectFileIndex = project.service<ProjectFileIndex>()
|
||||
project.service<FileEditorManager>().openFiles
|
||||
.filter { projectFileIndex.isInContent(it) && !containsTag(it) }
|
||||
.toFileSuggestions()
|
||||
val matcher = NameUtil.buildMatcher("*$searchText").build()
|
||||
val matchingFiles = mutableListOf<VirtualFile>()
|
||||
|
||||
projectFileIndex.iterateContent { file ->
|
||||
if (!file.isDirectory &&
|
||||
!containsTag(file) &&
|
||||
(searchText.isEmpty() || matcher.matchingDegree(file.name) != Int.MIN_VALUE)
|
||||
) {
|
||||
matchingFiles.add(file)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
val openFiles = project.service<FileEditorManager>().openFiles
|
||||
.filter {
|
||||
projectFileIndex.isInContent(it) &&
|
||||
!containsTag(it) &&
|
||||
(searchText.isEmpty() || matcher.matchingDegree(it.name) != Int.MIN_VALUE)
|
||||
}
|
||||
|
||||
(matchingFiles + openFiles).distinctBy { it.path }.toFileSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +78,6 @@ class FilesGroupItem(
|
|||
private fun Iterable<VirtualFile>.toFileSuggestions(): List<LookupActionItem> {
|
||||
val selectedFileTags = TagUtil.getExistingTags(project, FileTagDetails::class.java)
|
||||
return filter { file -> selectedFileTags.none { it.virtualFile == file } }
|
||||
.take(10)
|
||||
.map { FileActionItem(project, it) } + listOf(IncludeOpenFilesActionItem())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue