diff --git a/src/main/kotlin/ee/carlrobert/codegpt/psistructure/PsiStructureProvider.kt b/src/main/kotlin/ee/carlrobert/codegpt/psistructure/PsiStructureProvider.kt index aa13e7cb3..f90b4f209 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/psistructure/PsiStructureProvider.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/psistructure/PsiStructureProvider.kt @@ -5,13 +5,9 @@ import com.intellij.openapi.application.ReadAction import com.intellij.psi.PsiFile import com.intellij.util.io.await import ee.carlrobert.codegpt.psistructure.models.ClassStructure -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive +import ee.carlrobert.codegpt.util.coroutines.runCatchingCancellable +import kotlinx.coroutines.* import org.jetbrains.kotlin.psi.KtFile -import kotlin.coroutines.cancellation.CancellationException class PsiStructureProvider { @@ -26,7 +22,7 @@ class PsiStructureProvider { while (result == null && attempts < maxAttempts) { attempts++ - try { + runCatchingCancellable { val project = psiFiles .map { it.project } .firstOrNull { !it.isDisposed } ?: error("Project not available") @@ -60,9 +56,7 @@ class PsiStructureProvider { .submit(Dispatchers.Default.asExecutor()) result = future.await() - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { + }.onFailure { delay(DELAY_RESTART_READ_ACTION) } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt index 68592f71b..9e83a1e97 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -1,12 +1,9 @@ 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.ide.IdeEventQueue import com.intellij.openapi.Disposable +import com.intellij.openapi.application.EDT 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.editor.Editor @@ -19,25 +16,39 @@ 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.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.EditorTextField import com.intellij.ui.JBColor +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.scale.JBUIScale import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT 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.* 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 ee.carlrobert.codegpt.ui.textarea.popup.LookupListCellRenderer +import ee.carlrobert.codegpt.ui.textarea.popup.LookupListModel +import ee.carlrobert.codegpt.util.coroutines.runCatchingCancellable import kotlinx.coroutines.* -import java.awt.Dimension +import java.awt.* +import java.awt.event.* import java.util.* +import javax.swing.AbstractAction +import javax.swing.KeyStroke +import javax.swing.ListSelectionModel +import javax.swing.SwingUtilities +import kotlin.math.min class PromptTextField( private val project: Project, @@ -48,11 +59,16 @@ class PromptTextField( private val onSubmit: (String) -> Unit, ) : EditorTextField(project, FileTypes.PLAIN_TEXT), Disposable { - private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val coroutineScope = CoroutineScope(Dispatchers.EDT + SupervisorJob()) private var showSuggestionsJob: Job? = null + private var showLoadingDelayedJob: Job? = null + private var searchJob: Job? = null val dispatcherId: UUID = UUID.randomUUID() - var lookup: LookupImpl? = null + private var currentPopup: JBPopup? = null + private var currentPopupPanel: PopupPanel? = null + private var currentParentGroup: LookupGroupItem? = null + private var currentItems: List = emptyList() init { isOneLineMode = false @@ -62,9 +78,8 @@ class PromptTextField( override fun onEditorAdded(editor: Editor) { IdeEventQueue.getInstance().addDispatcher( - PromptTextFieldEventDispatcher(dispatcherId, onBackSpace, lookup) { - val shown = lookup?.let { it.isShown && !it.isLookupDisposed } == true - if (shown) { + PromptTextFieldEventDispatcher(dispatcherId, onBackSpace) { + if (currentPopup?.isVisible == true) { return@PromptTextFieldEventDispatcher } @@ -91,46 +106,455 @@ class PromptTextField( MCPGroupItem(), WebActionItem(tagManager) ) - .filter { it.enabled } - .map { it.createLookupElement() } - .toTypedArray() - withContext(Dispatchers.Main) { - editor?.let { - showGroupLookup(it, lookupItems) + withContext(Dispatchers.EDT) { + editor?.let { showPopupLookup(it, lookupItems) } + } + } + + private fun showPopupLookup(editor: Editor, items: List) { + currentPopup?.cancel() + currentItems = items + + val popupPanel = createPopupPanel(items, null) + currentPopupPanel = popupPanel + + val popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(popupPanel, popupPanel.itemsList) + .setFocusable(true) + .setRequestFocus(true) + .setResizable(true) + .setCancelOnClickOutside(true) + .setCancelOnWindowDeactivation(true) + .setShowBorder(true) + .setShowShadow(true) + .setBorderColor(JBColor.border()) + .createPopup() + + val relativePoint = calculateOptimalPopupPosition(editor, popupPanel) + + currentPopup = popup + popup.show(relativePoint) + + runInEdt { + popupPanel.itemsList.requestFocus() + } + } + + private fun createPopupPanel(items: List, parentGroup: LookupGroupItem?): PopupPanel { + return PopupPanel(items, parentGroup) + } + + private inner class PopupPanel( + items: List, + val parentGroup: LookupGroupItem? + ) : JBPanel(BorderLayout()) { + + private val listModel = LookupListModel(items) + val itemsList = JBList(listModel) + private var currentParentGroup: LookupGroupItem? = parentGroup + + init { + setupList() + setupLayout() + updateSearchFromEditor() + } + + fun updateItems(newItems: List, newParentGroup: LookupGroupItem?) { + currentParentGroup = newParentGroup + updateListItems(newItems) + } + + private fun setupList() { + itemsList.apply { + cellRenderer = LookupListCellRenderer() + selectionMode = ListSelectionModel.SINGLE_SELECTION + if (model.size > 0) selectedIndex = 0 + + background = UIUtil.getListBackground() + foreground = UIUtil.getListForeground() + selectionBackground = UIUtil.getListSelectionBackground(true) + selectionForeground = UIUtil.getListSelectionForeground(true) + + fixedCellHeight = JBUIScale.scale(20) + border = JBUI.Borders.empty() + + isFocusTraversalPolicyProvider = false + setFocusTraversalKeysEnabled(false) + + inputMap.get(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0))?.also { actionUp -> + actionMap.put(actionUp, object : AbstractAction() { + override fun actionPerformed(e: ActionEvent?) { + var newIndex = selectedIndex - 1 + while (newIndex >= 0 && !listModel.getElementAt(newIndex).enabled) { + newIndex-- + } + if (newIndex < 0) { + newIndex = model.size - 1 + while (newIndex >= 0 && !listModel.getElementAt(newIndex).enabled) { + newIndex-- + } + } + println("sssssss VK_UP $newIndex") + if (newIndex >= 0) { + selectedIndex = newIndex + } + } + }) + } + inputMap.get(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0))?.also { actionDown -> + actionMap.put(actionDown, object : AbstractAction() { + override fun actionPerformed(e: ActionEvent?) { + var newIndex = selectedIndex + 1 + while (newIndex < model.size && !listModel.getElementAt(newIndex).enabled) { + newIndex++ + } + if (newIndex >= model.size) { + newIndex = 0 + while (newIndex < model.size && !listModel.getElementAt(newIndex).enabled) { + newIndex++ + } + } + println("sssssss VK_DOWN $newIndex") + if (newIndex < model.size) { + selectedIndex = newIndex + } + } + }) + } + + addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + when (e.keyCode) { + KeyEvent.VK_ENTER, KeyEvent.VK_TAB -> { + selectCurrentItem() + e.consume() + } + + KeyEvent.VK_ESCAPE -> { + currentPopup?.cancel() + e.consume() + } + + KeyEvent.VK_DELETE, KeyEvent.VK_BACK_SPACE -> { + redirectKeyToEditor(e) + } + + else -> { + if (e.keyChar.isLetterOrDigit() || e.keyChar.isWhitespace() || + e.keyChar == '.' || e.keyChar == '/' || e.keyChar == '_' || e.keyChar == '-' + ) { + redirectKeyToEditor(e) + } + } + } + } + }) + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) { + selectCurrentItem() + } + } + }) } } + + private fun setupLayout() { + background = UIUtil.getListBackground() + border = JBUI.Borders.empty(4) + + isFocusTraversalPolicyProvider = false + setFocusTraversalKeysEnabled(false) + + val scrollPane = JBScrollPane(itemsList).apply { + border = JBUI.Borders.empty() + viewport.background = UIUtil.getListBackground() + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + isFocusTraversalPolicyProvider = false + setFocusTraversalKeysEnabled(false) + } + + add(scrollPane, BorderLayout.CENTER) + + addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + when (e.keyCode) { + KeyEvent.VK_ENTER, KeyEvent.VK_TAB -> { + if (itemsList.selectedIndex >= 0) { + selectCurrentItem() + e.consume() + } + } + + KeyEvent.VK_ESCAPE -> { + currentPopup?.cancel() + e.consume() + } + + else -> { + if (e.keyChar.isLetterOrDigit() || e.keyChar.isWhitespace() || + e.keyChar == '.' || e.keyChar == '/' || e.keyChar == '_' || e.keyChar == '-' + ) { + redirectKeyToEditor(e) + } + } + } + } + }) + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + g2.color = UIUtil.getListBackground() + g2.fillRoundRect(0, 0, width, height, JBUIScale.scale(8), JBUIScale.scale(8)) + } finally { + g2.dispose() + } + } + + private fun redirectKeyToEditor(e: KeyEvent) { + editor?.let { editor -> + runInEdt { + runUndoTransparentWriteAction { + when (e.keyCode) { + KeyEvent.VK_BACK_SPACE, KeyEvent.VK_DELETE -> { + val offset = editor.caretModel.offset + if (offset > 0) { + editor.document.deleteString(offset - 1, offset) + } + } + + else -> { + if (e.keyChar != KeyEvent.CHAR_UNDEFINED && e.keyChar.isDefined()) { + val offset = editor.caretModel.offset + editor.document.insertString(offset, e.keyChar.toString()) + editor.caretModel.moveToOffset(offset + 1) + } + } + } + } + } + e.consume() + } + } + + override fun getPreferredSize(): Dimension { + val maxWidth = JBUIScale.scale(450) + val minWidth = JBUIScale.scale(300) + val minHeight = JBUIScale.scale(120) + val maxHeight = JBUIScale.scale(300) + + val itemCount = itemsList.model.size + val itemHeight = JBUIScale.scale(20) + val contentHeight = (itemCount * itemHeight) + 8 + val contentWidth = calculateOptimalWidth() + + val width = minOf(maxOf(contentWidth, minWidth), maxWidth) + val height = minOf(maxOf(contentHeight, minHeight), maxHeight) + + return Dimension(width, height) + } + + private fun calculateOptimalWidth(): Int { + var maxWidth = JBUIScale.scale(200) + val fontMetrics = itemsList.getFontMetrics(itemsList.font) + + for (i in 0 until itemsList.model.size) { + val item = itemsList.model.getElementAt(i) + val textWidth = fontMetrics.stringWidth(item.displayName) + val iconWidth = item.icon?.iconWidth ?: 0 + val totalWidth = textWidth + iconWidth + JBUIScale.scale(32) + maxWidth = maxOf(maxWidth, totalWidth) + } + + return maxWidth + } + + fun updateSearchFromEditor() { + editor?.let { editor -> + when (val result = getSearchTextAfterAt(editor)) { + is SearchTextResult.Found -> { + updateFilter(result.query) + } + + is SearchTextResult.Cancelled -> { + currentPopup?.cancel() + } + + is SearchTextResult.None -> { + currentPopup?.cancel() + } + } + } + } + + fun updateFilter(searchText: String) { + searchJob?.cancel() + if (searchText.isEmpty()) { + updateListItems(this@PromptTextField.currentItems) + return + } + + searchJob = coroutineScope.launch { + val currentParentGroup = currentParentGroup + if (currentParentGroup != null && searchText.length >= 2) { + showLoadingState() + + runCatchingCancellable { + val items = if (currentParentGroup is DynamicLookupGroupItem) { + currentParentGroup.updateLookupItems(searchText) + } else { + currentParentGroup.getLookupItems(searchText) + } + .distinctBy { it.displayName } + + updateListItems(items) + } + .onFailure { + updateListItems(emptyList()) + } + } else if (searchText.length >= 2) { + showLoadingState() + runCatchingCancellable { + val filteredItems = this@PromptTextField.currentItems + .flatMap { item -> + when (item) { + is DynamicLookupGroupItem -> item.getLookupItems(searchText).take(5) + is LookupGroupItem -> item.getLookupItems(searchText).take(5) + else -> listOf(item) + } + } + .distinctBy { it.displayName } + .filter { it.displayName.contains(searchText, ignoreCase = true) } + yield() + updateListItems(filteredItems) + } + .onFailure { + yield() + updateListItems(emptyList()) + } + } else { + val filteredItems = this@PromptTextField.currentItems.filter { + it.displayName.contains(searchText, ignoreCase = true) + } + updateListItems(filteredItems) + } + } + } + + private fun showLoadingState() { + showLoadingDelayedJob?.cancel() + showLoadingDelayedJob = coroutineScope.launch { + delay(SHOW_LOADING_DELAY) + val loadingItems = listOf(LoadingLookupItem()) + val model = LookupListModel(loadingItems) + itemsList.model = model + itemsList.selectedIndex = -1 + } + } + + private fun updateListItems(items: List) { + showLoadingDelayedJob?.cancel() + runInEdt { + val model = LookupListModel(items) + itemsList.model = model + if (model.size > 0) { + itemsList.selectedIndex = 0 + } + } + } + + private fun selectCurrentItem() { + val selectedIndex = itemsList.selectedIndex + if (selectedIndex >= 0) { + val selectedItem = (itemsList.model as LookupListModel).getElementAt(selectedIndex) + + if (selectedItem is LoadingLookupItem || !selectedItem.enabled) { + return + } + + editor?.let { handleItemSelection(it, selectedItem) } + } + } + } + + private sealed class SearchTextResult { + data class Found(val query: String) : SearchTextResult() + data object Cancelled : SearchTextResult() + data object None : SearchTextResult() + } + + private fun getSearchTextAfterAt(editor: Editor): SearchTextResult { + val text = editor.document.text + val caretOffset = editor.caretModel.offset + val atPos = text.lastIndexOf('@', caretOffset) + + if (atPos !in 0 until caretOffset) { + return SearchTextResult.None + } + val endIndex = min(caretOffset + 1, text.length) + val substring = text.substring(atPos + 1, endIndex) + + return if (substring.contains(" ") || substring.contains("\n")) { + SearchTextResult.Cancelled + } else { + SearchTextResult.Found(substring) + } } - private fun showGroupLookup(editor: Editor, lookupElements: Array) { - lookup = createLookup(editor, lookupElements, "") - lookup?.addLookupListener(object : LookupListener { - override fun itemSelected(event: LookupEvent) { - val lookupString = event.item?.lookupString ?: return - val suggestion = - event.item?.getUserData(LookupItem.KEY) ?: return + private fun handleItemSelection(editor: Editor, item: LookupItem) { + when (item) { + is WebActionItem -> { val offset = editor.caretModel.offset - val start = offset - lookupString.length + val start = findAtSymbolPosition(editor) if (start >= 0) { runUndoTransparentWriteAction { editor.document.deleteString(start, offset) } } + onLookupAdded(item) + currentPopup?.cancel() + } - if (suggestion is WebActionItem) { - onLookupAdded(suggestion) + is LookupGroupItem -> { + showSuggestionsJob?.cancel() + showSuggestionsJob = coroutineScope.launch { + val suggestions = item.getLookupItems() + if (suggestions.isEmpty()) { + return@launch + } + + runInEdt { + updatePopupContent(suggestions, item) + } } + } - if (suggestion !is LookupGroupItem) return + is LookupActionItem -> { + replaceAtSymbol(editor, item) + onLookupAdded(item) + currentPopup?.cancel() + } + } + } - showSuggestionsJob?.cancel() - showSuggestionsJob = coroutineScope.launch { - showGroupSuggestions(suggestion) + private fun updatePopupContent(items: List, parentGroup: LookupGroupItem) { + currentPopup?.let { popup -> + if (popup.isVisible) { + currentPopupPanel?.let { panel -> + currentParentGroup = parentGroup + currentItems = items + panel.updateItems(items, parentGroup) + panel.itemsList.requestFocusInWindow() } } - }) - lookup?.refreshUi(false, true) - lookup?.showLookup() + } } private fun findAtSymbolPosition(editor: Editor): Int { @@ -144,102 +568,104 @@ class PromptTextField( return } - val lookupElements = suggestions.map { it.createLookupElement() }.toTypedArray() - - withContext(Dispatchers.Main) { - showSuggestionLookup(lookupElements, group) + withContext(Dispatchers.EDT) { + updatePopupContent(suggestions, group) } } - private fun createLookup( - editor: Editor, - lookupElements: Array, - searchText: String - ) = runReadAction { - LookupManager.getInstance(project).createLookup( - editor, - lookupElements, - searchText, - LookupArranger.DefaultArranger() - ) as LookupImpl + private fun showSuggestionPopup( + items: List, + parentGroup: LookupGroupItem + ) { + editor?.let { editor -> + currentPopup?.cancel() + currentParentGroup = parentGroup + + val popupPanel = createPopupPanel(items, parentGroup) + currentPopupPanel = popupPanel + + val popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(popupPanel, popupPanel.itemsList) + .setFocusable(true) + .setRequestFocus(true) + .setResizable(true) + .setCancelOnClickOutside(true) + .setCancelOnWindowDeactivation(true) + .createPopup() + + val relativePoint = calculateOptimalPopupPosition(editor, popupPanel) + + currentPopup = popup + popup.show(relativePoint) + + runInEdt { + popupPanel.itemsList.requestFocus() + } + } } - private fun showSuggestionLookup( - lookupElements: Array, - parentGroup: LookupGroupItem, - filterText: String = "", - ) { - editor?.let { - lookup = createLookup(it, lookupElements, 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 calculateOptimalPopupPosition(editor: Editor, popupPanel: PopupPanel): RelativePoint { + val caretPosition = editor.caretModel.visualPosition + val caretPoint = editor.visualPositionToXY(caretPosition) + val editorComponent = editor.contentComponent - 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) - } - } - } - } - }) + val caretLocationOnScreen = Point(caretPoint.x, caretPoint.y) + SwingUtilities.convertPointToScreen(caretLocationOnScreen, editorComponent) - 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) - } - } - } - } + val popupSize = popupPanel.preferredSize + val lineHeight = editor.lineHeight + val margin = JBUI.scale(8) - override fun afterTruncate() { - if (parentGroup is DynamicLookupGroupItem) { - val searchText = getSearchText() - if (searchText.isEmpty()) { - showSuggestionLookup(lookupElements, parentGroup, filterText) - } - } - } + val screenBounds = GraphicsEnvironment.getLocalGraphicsEnvironment() + .defaultScreenDevice.defaultConfiguration.bounds - private fun getSearchText(): String { - val text = it.document.text - return text.substring(text.lastIndexOf("@") + 1) - } + val spaceBelow = screenBounds.height - caretLocationOnScreen.y - lineHeight + val spaceAbove = caretLocationOnScreen.y + + val showAbove = popupSize.height + margin in (spaceBelow + 1)..spaceAbove + + val finalY = if (showAbove) { + caretLocationOnScreen.y - popupSize.height - margin + } else { + caretLocationOnScreen.y + lineHeight + margin + } + + val screenPoint = Point(caretLocationOnScreen.x, finalY) + + val editorLocationOnScreen = Point(0, 0) + SwingUtilities.convertPointToScreen(editorLocationOnScreen, editorComponent) - }, this) + val relativeX = screenPoint.x - editorLocationOnScreen.x + val relativeY = screenPoint.y - editorLocationOnScreen.y - lookup?.refreshUi(false, true) - lookup?.showLookup() + return RelativePoint(editorComponent, Point(relativeX, relativeY)) + } + + 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) + } + } } } @@ -256,7 +682,10 @@ class PromptTextField( } override fun dispose() { + searchJob?.cancel() showSuggestionsJob?.cancel() + currentPopup?.cancel() + currentPopupPanel = null } private fun setupDocumentListener(editor: EditorEx) { @@ -270,6 +699,12 @@ class PromptTextField( showSuggestionsJob = coroutineScope.launch { showGroupLookup() } + } else { + currentPopup?.let { popup -> + if (popup.isVisible) { + currentPopupPanel?.updateSearchFromEditor() + } + } } } }, this) @@ -291,4 +726,12 @@ class PromptTextField( return project.service() .getToolWindow("ProxyAI")?.component?.visibleRect?.height ?: 400 } + + private companion object { + /** + * Delay in milliseconds before showing the loading indicator + * to avoid flicker for quick operations. + */ + const val SHOW_LOADING_DELAY = 150L + } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt index f806d7f58..804ecf439 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt @@ -1,6 +1,5 @@ package ee.carlrobert.codegpt.ui.textarea -import com.intellij.codeInsight.lookup.impl.LookupImpl import com.intellij.ide.IdeEventQueue import com.intellij.openapi.application.runUndoTransparentWriteAction import com.intellij.openapi.util.TextRange @@ -16,7 +15,6 @@ import java.util.* class PromptTextFieldEventDispatcher( private val dispatcherId: UUID, private val onBackSpace: () -> Unit, - private val lookup: LookupImpl?, private val onSubmit: (KeyEvent) -> Unit ) : IdeEventQueue.EventDispatcher { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt index 77d51cb1d..50e1c513b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt @@ -258,8 +258,8 @@ class UserInputHeaderPanel( private inner class FileSelectionListener : FileEditorManagerListener { override fun selectionChanged(event: FileEditorManagerEvent) { event.newFile?.let { newFile -> - val containsTag = tagManager.getTags() - .none { it is EditorTagDetails && it.virtualFile == newFile } + val tags = tagManager.getTags() + val containsTag = !tags.contains(EditorTagDetails(newFile)) if (containsTag) { tagManager.addTag(EditorTagDetails(newFile).apply { selected = false }) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt index 4aee1f10a..b062fecb6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt @@ -3,7 +3,6 @@ package ee.carlrobert.codegpt.ui.textarea.header.tag import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service -import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.configuration.ConfigurationStateListener import java.util.concurrent.CopyOnWriteArraySet @@ -36,21 +35,6 @@ class TagManager(parentDisposable: Disposable) { fun getTags(): Set = synchronized(this) { tags.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 addTag(tagDetails: TagDetails) { val wasAdded = synchronized(this) { if (!service().state.chatCompletionSettings.editorContextTagEnabled diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LoadingLookupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LoadingLookupItem.kt new file mode 100644 index 000000000..ec394ab7a --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LoadingLookupItem.kt @@ -0,0 +1,24 @@ +package ee.carlrobert.codegpt.ui.textarea.lookup + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementPresentation +import com.intellij.icons.AllIcons +import ee.carlrobert.codegpt.CodeGPTBundle +import javax.swing.Icon + +class LoadingLookupItem : AbstractLookupItem() { + override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.loading.displayName") + override val icon: Icon = AllIcons.Process.Step_1 + + override fun setPresentation(element: LookupElement, presentation: LookupElementPresentation) { + presentation.icon = icon + presentation.itemText = displayName + presentation.isItemTextBold = false + presentation.isItemTextItalic = true + presentation.itemTextForeground = com.intellij.ui.JBColor.GRAY + } + + override fun getLookupString(): String { + return "loading_search" + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt index fa985d411..0cf1a172e 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupItem.kt @@ -2,7 +2,7 @@ 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 @@ -27,7 +27,7 @@ interface LookupGroupItem : LookupItem { } interface DynamicLookupGroupItem : LookupGroupItem { - suspend fun updateLookupList(lookup: LookupImpl, searchText: String) + suspend fun updateLookupItems(searchText: String): List } interface LookupActionItem : LookupItem { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupUtil.kt deleted file mode 100644 index a3346b7e3..000000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/LookupUtil.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ee.carlrobert.codegpt.ui.textarea.lookup - -import com.intellij.codeInsight.completion.PrefixMatcher -import com.intellij.codeInsight.completion.PrioritizedLookupElement -import com.intellij.codeInsight.lookup.impl.LookupImpl - -object LookupUtil { - - fun addLookupItem(lookup: LookupImpl, lookupItem: LookupItem, priority: Double = 5.0) { - if (!lookup.isLookupDisposed) { - lookup.addItem( - PrioritizedLookupElement.withPriority(lookupItem.createLookupElement(), priority), - PrefixMatcher.ALWAYS_TRUE - ) - lookup.refreshUi(true, true) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/AbstractLookupGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/AbstractLookupGroupItem.kt index d603ae4eb..b409c7fc3 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/AbstractLookupGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/AbstractLookupGroupItem.kt @@ -2,7 +2,6 @@ package ee.carlrobert.codegpt.ui.textarea.lookup.group import com.intellij.codeInsight.lookup.LookupElement import com.intellij.codeInsight.lookup.LookupElementPresentation -import com.intellij.icons.AllIcons import ee.carlrobert.codegpt.ui.textarea.lookup.AbstractLookupItem import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem @@ -11,8 +10,6 @@ abstract class AbstractLookupGroupItem : AbstractLookupItem(), LookupGroupItem { override fun setPresentation(element: LookupElement, presentation: LookupElementPresentation) { presentation.itemText = displayName presentation.icon = icon - presentation.setTypeText("", AllIcons.Icons.Ide.NextStep) - presentation.isTypeIconRightAligned = true presentation.isItemTextBold = false } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt index 74fac7264..aef354414 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FilesGroupItem.kt @@ -1,21 +1,19 @@ 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.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.isFile import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager -import ee.carlrobert.codegpt.ui.textarea.header.tag.TagUtil 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.LookupItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.FileActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.IncludeOpenFilesActionItem import kotlinx.coroutines.Dispatchers @@ -29,37 +27,151 @@ class FilesGroupItem( override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.files.displayName") override val icon = AllIcons.FileTypes.Any_type - override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) { - withContext(Dispatchers.Default) { - project.service().iterateContent { - if (!it.isDirectory && !containsTag(it)) { - val actionItem = FileActionItem(project, it) - runInEdt { - LookupUtil.addLookupItem(lookup, actionItem) - } - } - true - } - } + override suspend fun updateLookupItems(searchText: String): List { + return getFileItems(searchText) } override suspend fun getLookupItems(searchText: String): List { - return readAction { + return getFileItems(searchText) + } + + private suspend fun getFileItems(searchText: String): List { + return withContext(Dispatchers.IO) { + val fileEditorManager = project.service() val projectFileIndex = project.service() - project.service().openFiles - .filter { projectFileIndex.isInContent(it) && !containsTag(it) } - .toFileSuggestions() + + val (activeFiles, openFiles) = readAction { + val selectedFiles = fileEditorManager.selectedFiles + .filter { isValidFile(it, searchText, projectFileIndex) } + + val openFiles = fileEditorManager.openFiles.toList() + + val otherFiles = openFiles + .filter { it !in selectedFiles && isValidFile(it, searchText, projectFileIndex) } + + Pair(selectedFiles, otherFiles) + } + + val editorFilesCount = activeFiles.size + openFiles.size + val needFromFileSystem = maxOf(0, 30 - editorFilesCount) + + val filesFromSystem = mutableListOf() + if (needFromFileSystem > 0) { + val editorFilesSet = (activeFiles + openFiles).toSet() + + readAction { + projectFileIndex.iterateContent( + /* processor = */ { file -> + if (filesFromSystem.size >= needFromFileSystem) { + false + } else { + if (!editorFilesSet.contains(file)) { + filesFromSystem.add(file) + } + true + } + }, + /* filter = */ { file -> + !file.isDirectory && + isValidProjectFile(file, projectFileIndex) && + !containsTag(file) && + (searchText.isEmpty() || file.name.contains(searchText, ignoreCase = true)) + } + ) + } + } + + val allFiles = activeFiles + openFiles + filesFromSystem + + val result = allFiles + .map { FileActionItem(project, it) } + .toMutableList() + + if (searchText.isEmpty()) { + result.add(IncludeOpenFilesActionItem()) + } + + result.toList() } } + private fun isValidProjectFile(file: VirtualFile, projectFileIndex: ProjectFileIndex): Boolean { + return file.isFile && + !isExcludedFile(file) && + projectFileIndex.isInContent(file) && + !projectFileIndex.isInLibraryClasses(file) && + !projectFileIndex.isInLibrarySource(file) && + !projectFileIndex.isInGeneratedSources(file) + } + + private fun isExcludedFile(file: VirtualFile): Boolean { + return file.extension?.lowercase() in EXCLUDED_EXTENSIONS + } + + private fun isValidFile(file: VirtualFile, searchText: String, projectFileIndex: ProjectFileIndex): Boolean { + return isValidProjectFile(file, projectFileIndex) && + !containsTag(file) && + (searchText.isEmpty() || file.name.contains(searchText, ignoreCase = true)) + } + private fun containsTag(file: VirtualFile): Boolean { - return tagManager.containsTag(file) + val tags = tagManager.getTags() + return tags.contains(FileTagDetails(file)) } - private fun Iterable.toFileSuggestions(): List { - val selectedFileTags = TagUtil.getExistingTags(project, FileTagDetails::class.java) - return filter { file -> selectedFileTags.none { it.virtualFile == file } } - .take(10) - .map { FileActionItem(project, it) } + listOf(IncludeOpenFilesActionItem()) + private companion object { + val COMPILED_EXTENSIONS = setOf( + // Java/JVM languages + "class", "jar", "war", "ear", "aar", + + // C/C++/Objective-C + "o", "obj", "so", "dll", "dylib", "a", "lib", "framework", + + // .NET/C# + "exe", "pdb", "mdb", + + // Python + "pyc", "pyo", "pyd", + + // Rust + "rlib", + + // Go (compiled binaries often have no extension, but some cases) + "a", + + // Android + "dex", "apk", + + // iOS + "ipa", + + // Pascal/Delphi + "dcu", "dcp", + + // PHP + "phar", + + // Archives and packages + "zip", "tar", "gz", "bz2", "xz", "7z", "rar", + + // Other binary formats + "bin", "dat", "dump" + ) + + val TEMPORARY_EXTENSIONS = setOf( + // Backup files + "bak", "backup", "tmp", "temp", "swp", "swo", + + // IDE/Editor temporary files + "idea", "iml", "ipr", "iws", + + // OS temporary files + "ds_store", "thumbs.db", "desktop.ini", + + // Build artifacts + "log", "cache" + ) + + val EXCLUDED_EXTENSIONS = COMPILED_EXTENSIONS + TEMPORARY_EXTENSIONS } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt index 79622e5e1..335751fa8 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/FoldersGroupItem.kt @@ -1,17 +1,16 @@ 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 ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails 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.LookupItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.FolderActionItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -24,16 +23,20 @@ class FoldersGroupItem( override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.folders.displayName") override val icon = AllIcons.Nodes.Folder - override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) { - withContext(Dispatchers.Default) { + override suspend fun updateLookupItems(searchText: String): List { + return withContext(Dispatchers.IO) { + val items = mutableListOf() + val tags = tagManager.getTags() project.service().iterateContent { - if (it.isDirectory && !it.name.startsWith(".") && !tagManager.containsTag(it)) { - runInEdt { - LookupUtil.addLookupItem(lookup, FolderActionItem(project, it)) - } + if (it.isDirectory && !it.name.startsWith(".") && + !tags.contains(EditorTagDetails(it)) && + (searchText.isEmpty() || it.name.contains(searchText, ignoreCase = true)) + ) { + items.add(FolderActionItem(project, it)) } - true + items.size < 50 } + items } } @@ -51,8 +54,10 @@ class FoldersGroupItem( private suspend fun getProjectFolders(project: Project) = withContext(Dispatchers.IO) { val folders = mutableSetOf() + val tags = tagManager.getTags() project.service().iterateContent { file: VirtualFile -> - if (file.isDirectory && !file.name.startsWith(".") && !tagManager.containsTag(file)) { + if (file.isDirectory && !file.name.startsWith(".") && + !tags.contains(EditorTagDetails(file))) { val folderPath = file.path if (folders.none { it.path.startsWith(folderPath) }) { folders.removeAll { it.path.startsWith(folderPath) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt index 29a3cd3c8..9bb84b787 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/GitGroupItem.kt @@ -1,14 +1,11 @@ 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.LookupItem 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,28 +18,37 @@ 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 -> + private var allAvailableItems: List = emptyList() + + override suspend fun updateLookupItems(searchText: String): List { + return withContext(Dispatchers.Default) { + val items = mutableListOf() + GitUtil.getProjectRepository(project)?.let { repository -> + GitUtil.visitRepositoryCommits(project, repository) { commit -> if (commit.id.asString().contains(searchText, true) || commit.fullMessage.contains(searchText, true) ) { - runInEdt { - LookupUtil.addLookupItem(lookup, GitCommitActionItem(commit)) - } + items.add(GitCommitActionItem(commit)) } + items.size < 50 // Ограничиваем количество результатов } } + items } } override suspend fun getLookupItems(searchText: String): List { return withContext(Dispatchers.Default) { - GitUtil.getProjectRepository(project)?.let { - val recentCommits = GitUtil.getAllRecentCommits(project, it, searchText) - .take(10) - .map { commit -> GitCommitActionItem(commit) } + GitUtil.getProjectRepository(project)?.let { repository -> + val recentCommits = if (searchText.isEmpty()) { + GitUtil.getAllRecentCommits(project, repository, "") + .take(10) + .map { commit -> GitCommitActionItem(commit) } + } else { + GitUtil.getAllRecentCommits(project, repository, searchText) + .take(10) + .map { commit -> GitCommitActionItem(commit) } + } listOf(IncludeCurrentChangesActionItem()) + recentCommits } ?: emptyList() } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/MCPGroupItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/MCPGroupItem.kt index 8c56d835e..cd0c5a5b1 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/MCPGroupItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/group/MCPGroupItem.kt @@ -14,6 +14,7 @@ class MCPGroupItem : AbstractLookupGroupItem() { override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.mcp.displayName") override val icon: Icon = Icons.MCP + override val enabled: Boolean = false override fun setPresentation(element: LookupElement, presentation: LookupElementPresentation) { super.setPresentation(element, presentation) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/popup/LookupListCellRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/popup/LookupListCellRenderer.kt new file mode 100644 index 000000000..7fea315a8 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/popup/LookupListCellRenderer.kt @@ -0,0 +1,100 @@ +package ee.carlrobert.codegpt.ui.textarea.popup + +import com.intellij.icons.AllIcons +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.JBColor +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.scale.JBUIScale +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import ee.carlrobert.codegpt.ui.textarea.lookup.LoadingLookupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupItem +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Dimension +import javax.swing.* + +class LookupListCellRenderer : ListCellRenderer { + + override fun getListCellRendererComponent( + list: JList, + value: LookupItem, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val panel = JPanel(BorderLayout()).apply { + preferredSize = Dimension(list.width, ITEM_HEIGHT) + border = JBUI.Borders.empty(0, 0, 0, 0) + } + + val component = SimpleColoredComponent().apply { + icon = if (value.enabled) value.icon else value.icon?.let { IconLoader.getDisabledIcon(it) } + iconTextGap = ICON_TEXT_GAP + isOpaque = false + ipad = JBUI.insets(TOP_BOTTOM_MARGIN, LEFT_MARGIN, TOP_BOTTOM_MARGIN, 0) + + when { + !value.enabled -> { + append(value.displayName, SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + value is LoadingLookupItem -> { + append(value.displayName, SimpleTextAttributes.GRAYED_ITALIC_ATTRIBUTES) + } + value is LookupGroupItem -> { + append(value.displayName, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) + } + else -> { + append(value.displayName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + } + } + } + + panel.add(component, BorderLayout.CENTER) + + if (value is LookupGroupItem) { + val arrowLabel = JLabel().apply { + icon = if (value.enabled) AllIcons.Icons.Ide.NextStep else IconLoader.getDisabledIcon(AllIcons.Icons.Ide.NextStep) + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + border = JBUI.Borders.empty(0, JBUIScale.scale(4), 0, RIGHT_MARGIN) + isOpaque = false + + if (isSelected && value.enabled) { + foreground = UIUtil.getListSelectionForeground(true) + } else { + foreground = if (value.enabled) UIUtil.getListForeground() else JBUI.CurrentTheme.Label.disabledForeground() + } + } + panel.add(arrowLabel, BorderLayout.EAST) + } else { + val spacer = Box.createHorizontalStrut(RIGHT_MARGIN + JBUIScale.scale(16)) + panel.add(spacer, BorderLayout.EAST) + } + + if (isSelected && value.enabled) { + panel.background = UIUtil.getListSelectionBackground(true) + component.foreground = UIUtil.getListSelectionForeground(true) + } else { + panel.background = UIUtil.getListBackground() + component.foreground = when { + !value.enabled -> JBUI.CurrentTheme.Label.disabledForeground() + value is LoadingLookupItem -> JBColor.GRAY + else -> UIUtil.getListForeground() + } + } + + panel.isOpaque = true + return panel + } + + private companion object { + val ITEM_HEIGHT = JBUIScale.scale(20) + val ICON_TEXT_GAP = JBUIScale.scale(4) + val LEFT_MARGIN = JBUIScale.scale(8) + val RIGHT_MARGIN = JBUIScale.scale(8) + val TOP_BOTTOM_MARGIN = JBUIScale.scale(2) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/popup/LookupListModel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/popup/LookupListModel.kt new file mode 100644 index 000000000..304c47ab6 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/popup/LookupListModel.kt @@ -0,0 +1,11 @@ +package ee.carlrobert.codegpt.ui.textarea.popup + +import ee.carlrobert.codegpt.ui.textarea.lookup.LookupItem +import javax.swing.AbstractListModel + +class LookupListModel(private val items: List) : AbstractListModel() { + + override fun getSize(): Int = items.size + + override fun getElementAt(index: Int): LookupItem = items[index] +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/coroutines/CoroutineExtensions.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/coroutines/CoroutineExtensions.kt new file mode 100644 index 000000000..417b0fc61 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/coroutines/CoroutineExtensions.kt @@ -0,0 +1,13 @@ +package ee.carlrobert.codegpt.util.coroutines + +import kotlinx.coroutines.CancellationException + +inline fun runCatchingCancellable(action: () -> T): Result { + return try { + Result.success(action()) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } +} \ No newline at end of file diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index 183f75de1..92a2698c9 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -325,4 +325,5 @@ tagPopupMenuItem.closeAll=Close All Tags tagPopupMenuItem.closeTagsToLeft=Close Tags to the Left tagPopupMenuItem.closeTagsToRight=Close Tags to the Right toolwindow.chat.loading=Generating response... -headerPanel.error.searchBlockNotMapped.title=Failed to Locate Search Block \ No newline at end of file +headerPanel.error.searchBlockNotMapped.title=Failed to Locate Search Block +suggestionGroupItem.loading.displayName=Loading... \ No newline at end of file