diff --git a/src/main/kotlin/com/smallcloud/refactai/Initializer.kt b/src/main/kotlin/com/smallcloud/refactai/Initializer.kt index 24c1f159..50eda47d 100644 --- a/src/main/kotlin/com/smallcloud/refactai/Initializer.kt +++ b/src/main/kotlin/com/smallcloud/refactai/Initializer.kt @@ -4,10 +4,8 @@ import com.intellij.ide.plugins.PluginInstaller import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity -import com.intellij.openapi.util.Disposer import com.smallcloud.refactai.account.LoginStateService import com.smallcloud.refactai.account.login import com.smallcloud.refactai.io.ConnectivityManager @@ -27,9 +25,8 @@ class Initializer : ProjectActivity, Disposable { initialize(project) } private fun initialize(project: Project) { - val listener = LastEditorGetterListener() - Disposer.register(PluginState.instance, listener) - EditorFactory.getInstance().addEditorFactoryListener(listener, PluginState.instance) + val listener = LastEditorGetterListener.instance +// Disposer.register(PluginState.instance, listener) Logger.getInstance("SMCInitializer").info("Bin prefix = ${Resources.binPrefix}") ConnectivityManager.instance.startup() diff --git a/src/main/kotlin/com/smallcloud/refactai/Resources.kt b/src/main/kotlin/com/smallcloud/refactai/Resources.kt index caae71d2..72cf4156 100644 --- a/src/main/kotlin/com/smallcloud/refactai/Resources.kt +++ b/src/main/kotlin/com/smallcloud/refactai/Resources.kt @@ -64,9 +64,11 @@ object Resources { const val defaultCloudAuthLink: String = "https://refact.smallcloud.ai/authentication?token=%s&utm_source=plugin&utm_medium=jetbrains&utm_campaign=login" val defaultCloudUrl: URI = URI("https://www.smallcloud.ai") val defaultCodeCompletionUrlSuffix = URI("v1/code-completion") + val defaultContrastUrlSuffix = URI("v1/contrast") val defaultChatUrlSuffix = URI("v1/chat") val defaultRecallUrl: URI = defaultCloudUrl.resolve("/v1/streamlined-login-recall-ticket") val loginSuffixUrl = URI("v1/login") + val defaultLikeReportUrl: URI = defaultCloudUrl.resolve("/v1/longthink-like") val defaultLoginUrl: URI = defaultCloudUrl.resolve(loginSuffixUrl) val defaultReportUrlSuffix: URI = URI("v1/telemetry-network") val defaultSnippetAcceptedUrlSuffix: URI = URI("v1/snippet-accepted") @@ -77,6 +79,7 @@ object Resources { const val loginCoolDown: Int = 300 // sec const val titleStr: String = "RefactAI" val pluginId: PluginId = getPluginId() + const val stagingFilterPrefix: String = "STAGING" val jbBuildVersion: String = ApplicationInfo.getInstance().build.toString() const val refactAIRootSettingsID = "refactai_root" diff --git a/src/main/kotlin/com/smallcloud/refactai/account/LoginUser.kt b/src/main/kotlin/com/smallcloud/refactai/account/LoginUser.kt index c28340f4..a55917c0 100644 --- a/src/main/kotlin/com/smallcloud/refactai/account/LoginUser.kt +++ b/src/main/kotlin/com/smallcloud/refactai/account/LoginUser.kt @@ -12,8 +12,13 @@ import com.smallcloud.refactai.Resources.defaultRecallUrl import com.smallcloud.refactai.Resources.loginSuffixUrl import com.smallcloud.refactai.io.ConnectionStatus import com.smallcloud.refactai.io.sendRequest +import com.smallcloud.refactai.listeners.QuickLongthinkActionsService.Companion.instance as QuickLongthinkActionsServiceInstance +import com.smallcloud.refactai.aitoolbox.LongthinkFunctionProvider.Companion.instance as DiffIntentProviderInstance +import com.smallcloud.refactai.settings.ExtraState.Companion.instance as ExtraState import com.smallcloud.refactai.statistic.UsageStatistic import com.smallcloud.refactai.struct.DeploymentMode +import com.smallcloud.refactai.struct.LongthinkFunctionEntry +import com.smallcloud.refactai.utils.makeGson import org.apache.http.client.utils.URIBuilder import java.net.URI import com.smallcloud.refactai.account.AccountManager.Companion.instance as AccountManager @@ -110,7 +115,7 @@ private fun tryLoginWithApiKey(): String { try { val result = sendRequest(url, "GET", headers, requestProperties = mapOf("redirect" to "follow", "cache" to "no-cache", "referrer" to "no-referrer")) - val gson = Gson() + val gson = makeGson() val body = gson.fromJson(result.body, JsonObject::class.java) val retcode = body.get("retcode").asString val humanReadableMessage = if (body.has("human_readable_message")) body.get("human_readable_message").asString else "" @@ -135,6 +140,20 @@ private fun tryLoginWithApiKey(): String { PluginState.instance.loginMessage = body.get("login_message").asString } + if (body.has("longthink-functions-today-v2")) { + val cloudEntries = body.get("longthink-functions-today-v2").asJsonObject.entrySet().map { + val elem = gson.fromJson(it.value, LongthinkFunctionEntry::class.java) + elem.entryName = it.key + return@map elem.mergeLocalInfo(ExtraState.getLocalLongthinkInfo(elem.entryName)) + } + DiffIntentProviderInstance.defaultThirdPartyFunctions = cloudEntries + QuickLongthinkActionsServiceInstance.recreateActions() + } + + if (body.has("longthink-filters")) { + val filters = body.get("longthink-filters").asJsonArray.map { it.asString } + DiffIntentProviderInstance.intentFilters = filters + } if (body.has("metering_balance")) { AccountManager.meteringBalance = body.get("metering_balance").asInt } diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/LongthinkAction.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/LongthinkAction.kt new file mode 100644 index 00000000..4782017b --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/LongthinkAction.kt @@ -0,0 +1,26 @@ +package com.smallcloud.refactai.aitoolbox + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.ui.getUserData +import com.intellij.openapi.util.Key +import com.smallcloud.refactai.listeners.AIToolboxInvokeAction +import com.smallcloud.refactai.listeners.LastEditorGetterListener +import com.smallcloud.refactai.struct.LongthinkFunctionEntry +import com.smallcloud.refactai.struct.LongthinkFunctionVariation +import javax.swing.JComponent + +val LongthinkKey = Key.create("refact.longthink") + +class LongthinkAction: DumbAwareAction() { + override fun actionPerformed(e: AnActionEvent) { + val longthink = (e.inputEvent?.component as JComponent).getUserData(LongthinkKey) + if (longthink?.entryName?.isNotEmpty() == true) { + doActionPerformed(longthink.functions.first()) + } + } + fun doActionPerformed(longthink: LongthinkFunctionEntry) { + LastEditorGetterListener.LAST_EDITOR?.let { AIToolboxInvokeAction().doActionPerformed(it, longthink) } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/LongthinkFunctionProvider.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/LongthinkFunctionProvider.kt new file mode 100644 index 00000000..e7f3c060 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/LongthinkFunctionProvider.kt @@ -0,0 +1,112 @@ +package com.smallcloud.refactai.aitoolbox + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.messages.Topic +import com.smallcloud.refactai.struct.LongthinkFunctionEntry +import com.smallcloud.refactai.struct.LongthinkFunctionVariation +import com.smallcloud.refactai.struct.ShortLongthinkHistoryInfo +import com.smallcloud.refactai.settings.ExtraState.Companion.instance as ExtraState + +interface LongthinkFunctionProviderChangedNotifier { + fun longthinkFunctionsChanged(functions: List) {} + fun longthinkFiltersChanged(filters: List) {} + + companion object { + val TOPIC = Topic.create( + "Longthink Function Provider Changed Notifier", + LongthinkFunctionProviderChangedNotifier::class.java + ) + } +} + + +class LongthinkFunctionProvider: Disposable { + private var _cloudIntents: List = emptyList() + private var _intentFilters: List = emptyList() + + var intentFilters: List + get() = _intentFilters + set(newList) { + if (_intentFilters != newList) { + _intentFilters = newList + ApplicationManager.getApplication().messageBus + .syncPublisher(LongthinkFunctionProviderChangedNotifier.TOPIC) + .longthinkFiltersChanged(_intentFilters) + } + } + + val functionVariations: List + get() { + val functionNameToVariations = mutableMapOf, + MutableList>>() + + for (func in defaultThirdPartyFunctions) { + val matchedFilter = _intentFilters.firstOrNull { func.functionName.endsWith(it) } + val funcName = if (matchedFilter != null) + func.functionName.substring(0, func.functionName.length - matchedFilter.length - 1) else + func.functionName + val filtersAndVariations = functionNameToVariations.getOrPut(funcName) { Pair(mutableListOf(), mutableListOf()) } + filtersAndVariations.first.add(matchedFilter ?: "") + filtersAndVariations.second.add(func) + } + return functionNameToVariations.map { LongthinkFunctionVariation(it.value.second, it.value.first) } + } + + var defaultThirdPartyFunctions: List + get(): List { + return _cloudIntents.filter { !(it.functionName.contains("free-chat") || + it.functionName.contains("completion")) } + } + set(newList) { + if (_cloudIntents != newList) { + _cloudIntents = newList + ApplicationManager.getApplication().messageBus + .syncPublisher(LongthinkFunctionProviderChangedNotifier.TOPIC) + .longthinkFunctionsChanged(_cloudIntents) + } + } + + var historyIntents: List + set(newVal) { + ExtraState.historyEntries = newVal.map { ShortLongthinkHistoryInfo.fromEntry(it) } + } + get() = ExtraState.historyEntries.map { shortInfo -> + var appropriateEntry = _cloudIntents.find { it.functionName == shortInfo.functionName } ?: return@map null + appropriateEntry = appropriateEntry.mergeShortInfo(shortInfo) + if (appropriateEntry.intent.isEmpty()) return@map null + appropriateEntry + }.filterNotNull() + + + fun pushFrontHistoryIntent(newEntry: LongthinkFunctionEntry) { + if (newEntry.intent.isEmpty()) return + var srcHints = historyIntents.filter { it.intent != newEntry.intent } + srcHints = srcHints.subList(0, minOf(srcHints.size, 20)) + historyIntents = listOf(newEntry) + srcHints + } + + fun lastHistoryEntry(): LongthinkFunctionEntry? { + return historyIntents.firstOrNull() + } + + val allChats: List + get() { + return _cloudIntents.filter { + it.functionName.contains("chat") && it.model?.isNotEmpty() ?: false + } + } + + companion object { + @JvmStatic + val instance: LongthinkFunctionProvider + get() = ApplicationManager.getApplication().getService(LongthinkFunctionProvider::class.java) + } + + override fun dispose() {} + + fun cleanUp() { + defaultThirdPartyFunctions = emptyList() + intentFilters = emptyList() + } +} diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/Mode.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/Mode.kt new file mode 100644 index 00000000..cf60338a --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/Mode.kt @@ -0,0 +1,6 @@ +package com.smallcloud.refactai.aitoolbox + +enum class Mode { + FILTER, + HISTORY, +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/State.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/State.kt new file mode 100644 index 00000000..05477c22 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/State.kt @@ -0,0 +1,40 @@ +package com.smallcloud.refactai.aitoolbox + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.smallcloud.refactai.listeners.LastEditorGetterListener +import com.smallcloud.refactai.struct.LongthinkFunctionEntry + + +object State { + var entry: LongthinkFunctionEntry = LongthinkFunctionEntry() + var currentIntent: String = "" + var historyIndex: Int = -1 + val startPosition: LogicalPosition = LogicalPosition(0, 0) + val finishPosition: LogicalPosition = LogicalPosition(0, 0) + val activeFilters: MutableSet = mutableSetOf() + + val activeMode: Mode + get() { + return if (historyIndex >= 0) { + Mode.HISTORY + } else { + Mode.FILTER + } + } + + val editor: Editor? + get() { + return LastEditorGetterListener.LAST_EDITOR + } + + val haveSelection: Boolean + get() { + var hasSelection = false + ApplicationManager.getApplication().invokeAndWait { + hasSelection = editor?.selectionModel?.hasSelection() ?: false + } + return hasSelection + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/ToolboxPane.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/ToolboxPane.kt new file mode 100644 index 00000000..9b1a3d3e --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/ToolboxPane.kt @@ -0,0 +1,710 @@ +package com.smallcloud.refactai.aitoolbox + +import com.intellij.icons.AllIcons +import com.intellij.ide.actions.ShowSettingsUtilImpl +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.VerticalFlowLayout +import com.intellij.ui.ColorUtil.* +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField +import com.intellij.ui.components.labels.LinkLabel +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.util.ui.FormBuilder +import com.intellij.util.ui.HTMLEditorKitBuilder +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil.getLabelForeground +import com.smallcloud.refactai.RefactAIBundle +import com.smallcloud.refactai.Resources +import com.smallcloud.refactai.Resources.refactAIRootSettingsID +import com.smallcloud.refactai.account.AccountManagerChangedNotifier +import com.smallcloud.refactai.aitoolbox.table.LongthinkTable +import com.smallcloud.refactai.aitoolbox.table.LongthinkTableModel +import com.smallcloud.refactai.aitoolbox.table.renderers.colorize +import com.smallcloud.refactai.aitoolbox.utils.getFilteredIntent +import com.smallcloud.refactai.aitoolbox.utils.getReasonForEntryFromState +import com.smallcloud.refactai.io.InferenceGlobalContextChangedNotifier +import com.smallcloud.refactai.listeners.SelectionChangedNotifier +import com.smallcloud.refactai.panes.RefactAIToolboxPaneFactory +import com.smallcloud.refactai.settings.ExtraState +import com.smallcloud.refactai.statistic.ExtraInfoService +import com.smallcloud.refactai.struct.DeploymentMode +import com.smallcloud.refactai.struct.LocalLongthinkInfo +import com.smallcloud.refactai.struct.LongthinkFunctionEntry +import com.smallcloud.refactai.struct.LongthinkFunctionVariation +import com.smallcloud.refactai.utils.getLastUsedProject +import com.smallcloud.refactai.utils.makeLinksPanel +import org.jdesktop.swingx.HorizontalLayout +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Dimension +import java.awt.Font +import java.awt.event.* +import javax.swing.* +import javax.swing.border.CompoundBorder +import javax.swing.border.EmptyBorder +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener +import com.smallcloud.refactai.account.AccountManager.Companion.instance as AccountManager +import com.smallcloud.refactai.aitoolbox.LongthinkFunctionProvider.Companion.instance as LongthinkFunctionProvider +import com.smallcloud.refactai.io.InferenceGlobalContext.Companion.instance as InferenceGlobalContext + + +private const val GLOBAL_MARGIN = 15 +private const val LEFT_RIGHT_GLOBAL_MARGIN = 10 + +class ToolboxPane(parent: Disposable) { + private val action = LongthinkAction() + private val filterTextField: JBTextField + private var longthinkList: LongthinkTable + private val longthinkScrollPane: JBScrollPane + private var previousIntent: String = "" + private var lastFocusedComponent: Component + private val longthinkDescriptionPane: JEditorPane = JEditorPane().apply { + editorKit = HTMLEditorKitBuilder().withWordWrapViewFactory().build() + isFocusable = true + isEditable = false + isOpaque = false + margin = JBUI.insets(GLOBAL_MARGIN) + document.addDocumentListener(object : DocumentListener { + override fun insertUpdate(e: DocumentEvent?) { + caretPosition = 0 + } + + override fun removeUpdate(e: DocumentEvent?) { + caretPosition = 0 + } + + override fun changedUpdate(e: DocumentEvent?) { + caretPosition = 0 + } + + }) + } + private var longthinkDescriptionScrollPane: JBScrollPane = JBScrollPane() + private val browser = if (JBCefApp.isSupported()) JBCefBrowser.createBuilder() + .setOffScreenRendering(false) + .setEnableOpenDevToolsMenuItem(true) + .build().also { + it.component.border = JBUI.Borders.empty() + } else null + + private val runButton = JButton("Run", AllIcons.Debugger.ThreadRunning).apply { + addActionListener { + doOKAction() + } + } + + private val meteringBalanceLabel: JBLabel = JBLabel().apply { + fun setup(balance: Int?) { + if (balance != null) { + text = (balance / 100).toString() + } + isVisible = balance != null + } + setup(AccountManager.meteringBalance) + toolTipText = RefactAIBundle.message("aiToolbox.meteringBalance") + icon = colorize(Resources.Icons.COIN_16x16, foreground) + ApplicationManager.getApplication() + .messageBus.connect(parent) + .subscribe(AccountManagerChangedNotifier.TOPIC, object : AccountManagerChangedNotifier { + override fun meteringBalanceChanged(newBalance: Int?) { + setup(newBalance) + } + }) + } + + private val filtersPanel = JPanel(HorizontalLayout(3)).apply { + border = JBUI.Borders.empty(0, 3) + } + private fun filterFunc(variation: LongthinkFunctionVariation): Boolean { + if (State.activeFilters.isEmpty()) return true + return variation.availableFilters.contains(State.activeFilters.first()) + } + + + private fun filterLongthinkList(text: String) { + longthinkList.filter(text) { filterFunc(it) } + longthinkList.selectionModel.setSelectionInterval(0, 0) + } + + private val historyIntents: List + get() { + return LongthinkFunctionProvider.historyIntents + } + + private var lastSelectedIndex = 0 + + init { + val longthinkVariations = LongthinkFunctionProvider.functionVariations + longthinkList = LongthinkTable(longthinkVariations, State.haveSelection) + filterTextField = object : JBTextField() { + private var hint: String = "↓ commands; ↑ history" + + init { + font = super.getFont().deriveFont(14f) + emptyText.text = hint + emptyText.setFont(font) + + val newSize = Dimension(maximumSize.width, preferredSize.height) + maximumSize = newSize + minimumSize = newSize + preferredSize = newSize + addKeyListener(object : KeyListener { + override fun keyTyped(e: KeyEvent?) {} + override fun keyReleased(e: KeyEvent?) { + if (e?.keyCode == KeyEvent.VK_ENTER) { + doOKAction() + } else if (e?.keyCode == KeyEvent.VK_BACK_SPACE || + e?.keyCode == KeyEvent.VK_DELETE + ) { + filterLongthinkList(text) + } else if (e?.keyCode == KeyEvent.VK_ESCAPE && isDescriptionVisible) { + isDescriptionVisible = false + } + } + + override fun keyPressed(e: KeyEvent?) { + if (e?.keyCode == KeyEvent.VK_UP || e?.keyCode == KeyEvent.VK_DOWN) { + if (e.keyCode == KeyEvent.VK_UP) { + State.historyIndex++ + } else if (e.keyCode == KeyEvent.VK_DOWN) { + State.historyIndex-- + } + State.historyIndex = minOf(maxOf(State.historyIndex, -2), historyIntents.size - 1) + if (State.historyIndex > -1) { + entry = historyIntents[State.historyIndex] + } else if (State.historyIndex == -1) { + text = previousIntent + functionVariation = getDefaultEntry() + } else if (State.historyIndex == -2) { + previousIntent = text + longthinkList.requestFocus() + longthinkList.selectionModel.setSelectionInterval(lastSelectedIndex, lastSelectedIndex) + return + } + } + } + }) + document.addDocumentListener(object : DocumentListener { + override fun insertUpdate(e: DocumentEvent?) { + if (State.activeMode == Mode.FILTER) { + previousIntent = text + filterLongthinkList(text) + } + State.currentIntent = text + isDescriptionVisible = false + } + + override fun removeUpdate(e: DocumentEvent?) { + if (State.activeMode == Mode.FILTER) { + previousIntent = text + filterLongthinkList(text) + functionVariation = getDefaultEntry() + } + State.currentIntent = text + isDescriptionVisible = false + } + + override fun changedUpdate(e: DocumentEvent?) { + if (State.activeMode == Mode.FILTER) { + previousIntent = text + filterLongthinkList(text) + functionVariation = getDefaultEntry() + } + State.currentIntent = text + isDescriptionVisible = false + } + }) + } + }.also { + it.addFocusListener(object : FocusListener { + override fun focusGained(e: FocusEvent?) { + lastFocusedComponent = it + } + override fun focusLost(e: FocusEvent?) {} + }) + } + longthinkList.also { + it.setupDescriptionColumn({ openDescriptionForEntry() }, + RefactAIBundle.message("aiToolbox.clickForDetails")) + it.addMouseListener(object : MouseListener { + override fun mouseClicked(event: MouseEvent?) { + if (event == null) return + if (event.clickCount == 2 && event.button == MouseEvent.BUTTON1) { + doOKAction() + return + } + if (event.clickCount == 1) { + try { + functionVariation = it.selectedValue + lastSelectedIndex = it.selectedIndex + } catch (_: Exception) { + Logger.getInstance(ToolboxPane::class.java).warn("Entry not found") + } + } + } + + override fun mousePressed(e: MouseEvent?) {} + override fun mouseReleased(e: MouseEvent?) {} + override fun mouseEntered(e: MouseEvent?) {} + override fun mouseExited(e: MouseEvent?) {} + }) + it.selectionModel.addListSelectionListener { e -> + if (it.selectedIndex == -1) return@addListSelectionListener + if (e == null) return@addListSelectionListener + try { + val tempEntry = it.selectedValue.apply { intent = filterTextField.text } + functionVariation = tempEntry + } catch (e: Exception) { + Logger.getInstance(ToolboxPane::class.java).warn(e.message) + } + } + it.addKeyListener(object : KeyListener { + override fun keyTyped(e: KeyEvent?) { + if (e?.keyChar?.isLetterOrDigit() == true) { + filterTextField.requestFocus() + filterTextField.dispatchEvent(e) + lastSelectedIndex = 0 + } + } + + override fun keyPressed(e: KeyEvent?) { + if (e?.keyCode == KeyEvent.VK_ENTER && getReasonForEntryFromState() == null) { + functionVariation = it.selectedValue + doOKAction() + } + } + + override fun keyReleased(e: KeyEvent?) { + if (e == null) return + if (e.keyCode == KeyEvent.VK_UP || e.keyCode == KeyEvent.VK_DOWN) { + if (e.keyCode == KeyEvent.VK_UP && lastSelectedIndex == it.selectedIndex) { + filterTextField.requestFocus() + filterTextField.text = previousIntent + State.historyIndex = -1 + functionVariation = getDefaultEntry() + } else lastSelectedIndex = it.selectedIndex + } + } + }) + it.addFocusListener(object : FocusListener { + override fun focusGained(e: FocusEvent?) { + State.historyIndex = -2 + filterTextField.text = previousIntent + lastFocusedComponent = longthinkList + } + + override fun focusLost(e: FocusEvent?) {} + }) + } + longthinkScrollPane = JBScrollPane( + longthinkList, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ).also { + it.preferredSize = it.maximumSize + } + if (browser != null) { + longthinkDescriptionScrollPane = JBScrollPane( + browser.component, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ).also { + it.border = JBUI.Borders.empty() + } + } + + lastFocusedComponent = filterTextField + class LongthinkFilterPanel(val text: String) : JPanel() { + var isActive = State.activeFilters.contains(text) + private var mouseInside: Boolean = false + + init { + add(JLabel(text).apply { + foreground = JBColor.background() + font = super.getFont().deriveFont(Font.BOLD, 10f) + }) + maximumSize = Dimension(preferredSize.width, preferredSize.height) + minimumSize = Dimension(preferredSize.width, preferredSize.height) + background = JBColor.lazy { + val isDark = isDark(EditorColorsManager.getInstance().globalScheme.defaultBackground) + if (mouseInside) return@lazy if (isDark) UIManager.getColor("Table.selectionForeground") else + JBColor.foreground() + if (isActive) return@lazy if (isDark) brighter(JBColor.foreground(), 2) else + darker(UIManager.getColor("Table.disabledForeground"), 6) + return@lazy if (isDark) darker(JBColor.foreground(), 4) else + brighter(UIManager.getColor("Table.disabledForeground"), 2) + } + addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent?) { + if (e == null) return + if (e.button == MouseEvent.BUTTON1) { + State.activeFilters.add(text) + isActive = true + updateUI() + modelChooserCB.selectedItem = text + (longthinkList.model as LongthinkTableModel).currentData.forEach { + it.activeFilter = text + } + filtersPanel.components.forEach { + if (it is LongthinkFilterPanel) { + if (this@LongthinkFilterPanel != it) { + it.isActive = false + State.activeFilters.remove(it.text) + it.updateUI() + } + } + } + + filterLongthinkList(filterTextField.text) + } + } + + override fun mouseEntered(e: MouseEvent?) { + mouseInside = true + updateUI() + } + override fun mouseExited(e: MouseEvent?) { + mouseInside = false + updateUI() + } + }) + } + } + filtersPanel.removeAll() + LongthinkFunctionProvider.intentFilters.forEach { + filtersPanel.add(LongthinkFilterPanel(it)) + } + + ApplicationManager.getApplication().messageBus + .connect(parent) + .subscribe(LongthinkFunctionProviderChangedNotifier.TOPIC, object : LongthinkFunctionProviderChangedNotifier { + override fun longthinkFunctionsChanged(functions: List) { + val model = longthinkList.model as LongthinkTableModel + model.data = LongthinkFunctionProvider.functionVariations + filterLongthinkList(filterTextField.text) + } + + override fun longthinkFiltersChanged(filters: List) { + State.activeFilters.clear() + if (filters.isNotEmpty()) State.activeFilters.add(filters.first()) + filtersPanel.removeAll() + filters.forEach { + filtersPanel.add(LongthinkFilterPanel(it)) + } + val model = longthinkList.model as LongthinkTableModel + model.data = LongthinkFunctionProvider.functionVariations + filterLongthinkList(filterTextField.text) + } + }) + ApplicationManager.getApplication().messageBus + .connect(parent) + .subscribe(SelectionChangedNotifier.TOPIC, object : SelectionChangedNotifier { + override fun isSelectionChanged(isSelection: Boolean) { + (longthinkList.model as LongthinkTableModel).fromHL = !isSelection + filterLongthinkList(filterTextField.text) + } + }) + } + + private val holder = JPanel().also { + it.layout = BorderLayout() + } + private val placeholder = JPanel().also { it -> + it.layout = BorderLayout() + it.add(JPanel().apply { + layout = VerticalFlowLayout(VerticalFlowLayout.MIDDLE) + + add(JBLabel(RefactAIBundle.message("aiToolbox.panes.toolbox.placeholder")).also { label -> + label.verticalAlignment = JBLabel.CENTER + label.horizontalAlignment = JBLabel.CENTER + label.isEnabled = false + }) + add(LinkLabel("Settings", null).also { label -> + label.verticalAlignment = JBLabel.CENTER + label.horizontalAlignment = JBLabel.CENTER + label.addMouseListener(object : MouseListener { + override fun mouseClicked(e: MouseEvent?) {} + override fun mousePressed(e: MouseEvent?) {} + override fun mouseEntered(e: MouseEvent?) {} + override fun mouseExited(e: MouseEvent?) {} + override fun mouseReleased(e: MouseEvent?) { + ShowSettingsUtilImpl.showSettingsDialog(getLastUsedProject(), + refactAIRootSettingsID, null) + } + }) + }) + }, BorderLayout.CENTER) + } + + private fun setupPanes(isAvailable: Boolean) { + invokeLater { + holder.removeAll() + if (isAvailable) { + holder.add(toolpaneComponent) + } else { + holder.add(placeholder) + } + } + } + init { + setupPanes(InferenceGlobalContext.isSelfHosted || + (InferenceGlobalContext.isCloud && AccountManager.isLoggedIn)) + ApplicationManager.getApplication() + .messageBus + .connect(parent) + .subscribe(InferenceGlobalContextChangedNotifier.TOPIC, + object : InferenceGlobalContextChangedNotifier { + override fun deploymentModeChanged(newMode: DeploymentMode) { + setupPanes(when (newMode) { + DeploymentMode.SELF_HOSTED -> { + true + } + DeploymentMode.CLOUD -> { + AccountManager.isLoggedIn + } + + DeploymentMode.HF -> { false } + }) + } + }) + ApplicationManager.getApplication() + .messageBus + .connect(parent) + .subscribe(AccountManagerChangedNotifier.TOPIC, + object : AccountManagerChangedNotifier { + override fun isLoggedInChanged(isLoggedIn: Boolean) { + setupPanes(InferenceGlobalContext.isSelfHosted || + (InferenceGlobalContext.isCloud && isLoggedIn)) + } + }) + } + + private var modelChooserCB = ComboBox().apply { + addItemListener { + val filter = it.item as String + functionVariation.activeFilter = if (filter == "refact") "" else filter + entry = functionVariation.getFunctionByFilter(functionVariation.activeFilter) + } + } + + private var likeButton: LinkLabel = LinkLabel(null, Resources.Icons.LIKE_CHECKED_24x24).apply { + addMouseListener(object : MouseListener { + override fun mouseClicked(e: MouseEvent?) {} + override fun mousePressed(e: MouseEvent?) {} + override fun mouseEntered(e: MouseEvent?) {} + override fun mouseExited(e: MouseEvent?) {} + override fun mouseReleased(e: MouseEvent?) { + entry.isLiked = !entry.isLiked + if (entry.isLiked) { + entry.likes++ + } else { + entry.likes-- + } + (longthinkList.model as LongthinkTableModel).isLikedChanged(functionVariation) + (longthinkList.model as LongthinkTableModel).filter(filterTextField.text) { filterFunc(it) } + icon = if (entry.isLiked) Resources.Icons.LIKE_CHECKED_24x24 else Resources.Icons.LIKE_UNCHECKED_24x24 + ExtraState.instance.insertLocalLongthinkInfo(entry.entryName, LocalLongthinkInfo.fromEntry(entry)) + ExtraInfoService.instance.addLike(entry.entryName, entry.isLiked) + } + }) + } + private var bookmarkButton: LinkLabel = LinkLabel(null, Resources.Icons.BOOKMARK_CHECKED_24x24).apply { + addMouseListener(object : MouseListener { + override fun mouseClicked(e: MouseEvent?) {} + override fun mousePressed(e: MouseEvent?) {} + override fun mouseEntered(e: MouseEvent?) {} + override fun mouseExited(e: MouseEvent?) {} + override fun mouseReleased(e: MouseEvent?) { + entry.isBookmarked = !entry.isBookmarked + (longthinkList.model as LongthinkTableModel).filter(filterTextField.text) { filterFunc(it) } + icon = if (entry.isBookmarked) Resources.Icons.BOOKMARK_CHECKED_24x24 else Resources.Icons.BOOKMARK_UNCHECKED_24x24 + ExtraState.instance.insertLocalLongthinkInfo(entry.entryName, LocalLongthinkInfo.fromEntry(entry)) + } + }) + } + + private val htmlStyle: String + get() { + val backgroundColor = toHtmlColor(JBColor.background()) + val fontColor = toHtmlColor(getLabelForeground()) + val fontSizePx = JLabel().run { + val metric = getFontMetrics(font) + metric.ascent + } + + return "" + } + + private fun openDescriptionForEntry() { + isDescriptionVisible = true + } + + private fun doOKAction() { + val filteredIntent = getFilteredIntent(filterTextField.text) + if (getReasonForEntryFromState(entry) == null || filteredIntent.endsWith("?")) { + val entry = entry.copy().apply { + intent = entry.modelFixedIntent.ifEmpty { + if (catchAny()) { + filterTextField.text + } else { + label + } + } + } + + if (filteredIntent.endsWith("?") || entry.catchQuestionMark) { + RefactAIToolboxPaneFactory.chat?.preview(filteredIntent, + State.editor?.selectionModel?.selectedText ?: "") + RefactAIToolboxPaneFactory.focusChat() + } else { + action.doActionPerformed(entry) + } + isDescriptionVisible = false + filterTextField.text = "" + lastSelectedIndex = 0 + State.historyIndex = -1 + LongthinkFunctionProvider.pushFrontHistoryIntent(entry) + } + } + + private fun getDefaultEntry(): LongthinkFunctionVariation { + return try { + (longthinkList.model as LongthinkTableModel).elementAt(0).apply { intent = filterTextField.text } + } catch (e: Exception) { + LongthinkFunctionVariation(listOf(LongthinkFunctionEntry(filterTextField.text)), listOf("")) + } + } + + private val longthinkLabel: JBLabel = JBLabel().apply { + font = JBUI.Fonts.create(font.family, 18) + val b = border + border = CompoundBorder(b, EmptyBorder(JBUI.insetsLeft(LEFT_RIGHT_GLOBAL_MARGIN))) + } + + private val mainComponent: JComponent = FormBuilder.createFormBuilder().apply { + addComponent(filtersPanel) + addComponent(longthinkScrollPane, GLOBAL_MARGIN) + }.panel + + private val descriptionComponent: JComponent = FormBuilder.createFormBuilder().apply { + addComponent(JPanel().apply { + layout = BorderLayout() + add(JButton("Back").apply { + addActionListener { + isDescriptionVisible = false + } + }, BorderLayout.WEST) + add(runButton, BorderLayout.EAST) + }) + addComponent(JPanel(BorderLayout()).apply { + val likeBookmark = JPanel() + val layout = BoxLayout(likeBookmark, BoxLayout.X_AXIS) + likeBookmark.layout = layout + likeBookmark.add(likeButton) + likeBookmark.add(Box.createRigidArea(Dimension(5, 0))) + likeBookmark.add(bookmarkButton) + add(likeBookmark, BorderLayout.LINE_END) + add(modelChooserCB, BorderLayout.LINE_START) + }) + addComponent(longthinkLabel) + addComponent(longthinkDescriptionScrollPane, GLOBAL_MARGIN) + }.panel.also { + it.isVisible = false + } + + private val toolpaneComponent = JPanel(VerticalFlowLayout()).apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(filterTextField) + add(mainComponent) + add(descriptionComponent) + add(JPanel().apply { + layout = BorderLayout() + border = JBUI.Borders.empty(LEFT_RIGHT_GLOBAL_MARGIN) + add(meteringBalanceLabel, BorderLayout.WEST) + add(makeLinksPanel(), BorderLayout.EAST) + }) + } + + private var isDescriptionVisible: Boolean + get() { + return descriptionComponent.isVisible + } + set(newVal) { + mainComponent.isVisible = !newVal + descriptionComponent.isVisible = newVal + } + + fun getComponent(): JComponent { + return holder + } + + private var lastCopyEntry: LongthinkFunctionEntry = LongthinkFunctionEntry() + + private var functionVariation: LongthinkFunctionVariation = + LongthinkFunctionVariation(listOf(LongthinkFunctionEntry()), listOf("")) + set(newVal) { + field = newVal + (modelChooserCB.model as DefaultComboBoxModel).removeAllElements() + (modelChooserCB.model as DefaultComboBoxModel).addAll(field.availableFilters.map { if (it == "") "refact" else it }) + modelChooserCB.selectedItem = if (field.activeFilter == "") "refact" else field.activeFilter + modelChooserCB.isVisible = field.availableFilters.size > 1 + entry = field.getFunctionByFilter() + } + + + var entry: LongthinkFunctionEntry + get() = State.entry + set(newVal) { + if (State.activeMode != Mode.HISTORY && newVal == lastCopyEntry) return + State.entry = newVal + val reason = getReasonForEntryFromState(entry) + if (State.activeMode == Mode.HISTORY) { + filterTextField.text = entry.intent + longthinkList.clearSelection() + } else { +// longthinkList.selectedValue = entry + } + if (reason != null) { + runButton.isEnabled = false + runButton.toolTipText = reason + } else { + runButton.isEnabled = true + runButton.toolTipText = null + } + likeButton.icon = if (State.entry.isLiked) Resources.Icons.LIKE_CHECKED_24x24 else + Resources.Icons.LIKE_UNCHECKED_24x24 + bookmarkButton.icon = if (State.entry.isBookmarked) Resources.Icons.BOOKMARK_CHECKED_24x24 else + Resources.Icons.BOOKMARK_UNCHECKED_24x24 + longthinkDescriptionPane.text = entry.miniHtml + browser?.loadHTML(entry.miniHtml + htmlStyle) + longthinkLabel.text = entry.label + lastCopyEntry = State.entry.copy() + } + + fun requestFocus() { + lastFocusedComponent.requestFocus() + } + fun isFocused(): Boolean { + return filterTextField.isFocusOwner || longthinkList.isFocusOwner + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/ToolboxPaneInvokeAction.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/ToolboxPaneInvokeAction.kt new file mode 100644 index 00000000..620f0cca --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/ToolboxPaneInvokeAction.kt @@ -0,0 +1,39 @@ +package com.smallcloud.refactai.aitoolbox + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.util.Key +import com.intellij.openapi.wm.ToolWindowManager +import com.smallcloud.refactai.Resources +import com.smallcloud.refactai.listeners.AIToolboxInvokeAction +import com.smallcloud.refactai.modes.ModeProvider.Companion.getOrCreateModeProvider +import com.smallcloud.refactai.panes.RefactAIToolboxPaneFactory +import com.smallcloud.refactai.utils.getLastUsedProject + +class ToolboxPaneInvokeAction: AnAction(Resources.Icons.LOGO_RED_16x16) { + private val rerunAction = AIToolboxInvokeAction() + + private fun getEditor(e: AnActionEvent): Editor? { + return CommonDataKeys.EDITOR.getData(e.dataContext) + ?: e.presentation.getClientProperty(Key(CommonDataKeys.EDITOR.name)) + } + override fun actionPerformed(e: AnActionEvent) { + val editor = getEditor(e) + if (editor != null && (getOrCreateModeProvider(editor).isInDiffMode() || + getOrCreateModeProvider(editor).isInHighlightMode())) { + rerunAction.actionPerformed(e) + return + } + + val tw = ToolWindowManager.getInstance(getLastUsedProject()).getToolWindow("Refact") + if (RefactAIToolboxPaneFactory.isToolboxFocused()) { + tw?.hide() + } else { + tw?.activate({ + RefactAIToolboxPaneFactory.focusToolbox() + }, false) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/filterUtil.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/filterUtil.kt new file mode 100644 index 00000000..865b0fa2 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/filterUtil.kt @@ -0,0 +1,43 @@ +package com.smallcloud.refactai.aitoolbox + +import com.smallcloud.refactai.Resources +import com.smallcloud.refactai.struct.LongthinkFunctionVariation + +private fun filterByString(source: List, + filterString: String, + filterBy: ((LongthinkFunctionVariation) -> Boolean)? = null): List { + var realFilter = filterString.lowercase() + while (realFilter.startsWith(" ")) { + realFilter = realFilter.substring(1) + } + val realStagingFilter = Resources.stagingFilterPrefix.lowercase() + " " + realFilter + return source.filter { + it.label.lowercase().startsWith(realFilter) || + it.label.lowercase().startsWith(realStagingFilter) || + it.catchAny() + }.filter { if (filterBy != null) filterBy(it) else true} +} + + +fun filter(source: List, filterStr: String, fromHL: Boolean, + filterBy: ((LongthinkFunctionVariation) -> Boolean)? = null): List { + val localFiltered = filterByString(source, filterStr, filterBy).toMutableList() + val filtered = if (fromHL) { + localFiltered.sortedWith(compareByDescending { it.isBookmarked } + .thenByDescending { it.catchAllHighlight } + .thenByDescending { it.catchAllSelection } + .thenByDescending { it.catchQuestionMark } + .thenByDescending { it.likes } + .thenByDescending { it.supportHighlight } + ) + } else { + localFiltered.sortedWith(compareByDescending { it.isBookmarked } + .thenByDescending { it.catchAllSelection } + .thenByDescending { it.catchAllHighlight } + .thenByDescending { it.catchQuestionMark } + .thenByDescending { it.likes } + .thenByDescending { it.supportHighlight } + ) + } + return filtered.ifEmpty { source } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/LongthinkTable.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/LongthinkTable.kt new file mode 100644 index 00000000..1f7f78cd --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/LongthinkTable.kt @@ -0,0 +1,124 @@ +package com.smallcloud.refactai.aitoolbox.table + +import com.intellij.icons.AllIcons +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import com.smallcloud.refactai.Resources.Icons.BOOKMARK_CHECKED_16x16 +import com.smallcloud.refactai.Resources.Icons.DESCRIPTION_16x16 +import com.smallcloud.refactai.aitoolbox.LongthinkAction +import com.smallcloud.refactai.aitoolbox.table.renderers.IconRenderer +import com.smallcloud.refactai.aitoolbox.table.renderers.LabelRenderer +import com.smallcloud.refactai.aitoolbox.table.renderers.LikeRenderer +import com.smallcloud.refactai.aitoolbox.utils.getReasonForEntryFromState +import com.smallcloud.refactai.struct.LongthinkFunctionVariation +import java.awt.Point +import javax.swing.JMenuItem +import javax.swing.JPopupMenu +import javax.swing.ListSelectionModel + +class LongthinkTable( + data: List, + fromHL: Boolean, +) : JBTable(LongthinkTableModel(data, fromHL)) { + private var descriptionFunc: () -> Unit = {} + override fun getComponentPopupMenu(): JPopupMenu { + if (mousePosition == null || model.rowCount == 0) return JPopupMenu() + val p: Point = mousePosition + val row = rowAtPoint(p) + val model = model as LongthinkTableModel + val longthink = model.elementAt(row) + val reason = getReasonForEntryFromState(longthink.getFunctionByFilter()) + selectedValue = longthink + + val action = LongthinkAction() + val popup = JPopupMenu().also { popup -> + popup.add(JMenuItem("Run", AllIcons.Debugger.ThreadRunning).also { + it.isEnabled = reason == null + it.addActionListener { + action.doActionPerformed(longthink.getFunctionByFilter()) + } + }) + if (JBCefApp.isSupported()) { + popup.add(JMenuItem("Describe", DESCRIPTION_16x16).also { + it.addActionListener { + descriptionFunc() + } + }) + } + } + return popup + } + + fun setupDescriptionColumn(f: () -> Unit, toolTipText: String) { + if (JBCefApp.isSupported()) { + descriptionFunc = f + columnModel.getColumn(3).also { + val renderer = (it.cellEditor as com.smallcloud.refactai.aitoolbox.table.renderers.IconRenderer) + renderer.mousePressedFunction = descriptionFunc + renderer.toolTipText = toolTipText + } + } + } + + init { + showVerticalLines = false + tableHeader = null + setShowGrid(false) + columnSelectionAllowed = false + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + border = JBUI.Borders.empty() + visibleRowCount = 10 + font = super.getFont().deriveFont(14f) + + columnModel.getColumn(0).also { + it.cellRenderer = LabelRenderer() + it.minWidth = 200 + } + + columnModel.getColumn(2).also { + val renderer = LikeRenderer() + it.cellRenderer = renderer + val maxLikeLength = renderer.getFontMetrics(renderer.font).stringWidth("999+") + it.preferredWidth = renderer.icon.iconWidth + 4 + maxLikeLength + it.minWidth = renderer.icon.iconWidth + 4 + maxLikeLength + it.maxWidth = renderer.icon.iconWidth + 4 + maxLikeLength + } + + columnModel.getColumn(1).also { + val renderer = IconRenderer(BOOKMARK_CHECKED_16x16) + it.cellRenderer = renderer + it.preferredWidth = renderer.originalIcon.iconWidth + 4 + it.minWidth = renderer.originalIcon.iconWidth + 4 + it.maxWidth = renderer.originalIcon.iconWidth + 4 + } + if (JBCefApp.isSupported()) { + columnModel.getColumn(3).also { + val renderer = IconRenderer(DESCRIPTION_16x16) + it.cellRenderer = renderer + it.cellEditor = renderer + it.preferredWidth = renderer.originalIcon.iconWidth + 4 + it.minWidth = renderer.originalIcon.iconWidth + 4 + it.maxWidth = renderer.originalIcon.iconWidth + 4 + } + } + } + + var selectedValue: LongthinkFunctionVariation + get() { + return (model as LongthinkTableModel).elementAt(selectedRow) + } + set(newVal) { + val row = (model as LongthinkTableModel).indexOf(newVal) + selectionModel.setSelectionInterval(row, row) + } + + val selectedIndex: Int + get() { + return selectedRow + } + + fun filter(str: String, filterBy: ((LongthinkFunctionVariation) -> Boolean)? = null) { + (model as LongthinkTableModel).filter(str, filterBy) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/LongthinkTableModel.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/LongthinkTableModel.kt new file mode 100644 index 00000000..fe19839a --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/LongthinkTableModel.kt @@ -0,0 +1,77 @@ +package com.smallcloud.refactai.aitoolbox.table + +import com.intellij.ui.jcef.JBCefApp +import com.smallcloud.refactai.aitoolbox.State +import com.smallcloud.refactai.struct.LongthinkFunctionVariation +import javax.swing.table.AbstractTableModel + +class LongthinkTableModel(private var source: List, + private var fromHL_: Boolean) : AbstractTableModel() { + private lateinit var filtered: List + + val currentData: List + get() { + return filtered + } + + init { + filter(State.currentIntent) + } + + var data: List + get() { + return source + } + set(newData) { + source = newData + } + + var fromHL: Boolean = false + set(newFromHL) { + fromHL_ = newFromHL + } + + + override fun getRowCount(): Int { + return filtered.size + } + + fun filter(filterStr: String, filterBy: ((LongthinkFunctionVariation) -> Boolean)? = null) { + filtered = com.smallcloud.refactai.aitoolbox.filter(source, filterStr, fromHL_, filterBy) + this.fireTableDataChanged() + } + + override fun getColumnCount(): Int { + return if (JBCefApp.isSupported()) 4 else 3 + } + + fun isLikedChanged(entry: LongthinkFunctionVariation) { + val idx = filtered.indexOf(entry) + fireTableRowsUpdated(idx, idx) + } + + fun elementAt(index: Int): LongthinkFunctionVariation { + return filtered[index] + } + + fun indexOf(entry: LongthinkFunctionVariation): Int { + return filtered.indexOf(entry) + } + + override fun getValueAt(rowIndex: Int, columnIndex: Int): Any? { + if (rowIndex >= filtered.size) return null + val rec = elementAt(rowIndex) + return when (columnIndex) { + 0 -> rec.label + 1 -> rec.isBookmarked + 2 -> Pair(rec.likes, rec.isLiked) + else -> true + } + } + + override fun setValueAt(aValue: Any, rowIndex: Int, columnIndex: Int) {} + + override fun isCellEditable(rowIndex: Int, columnIndex: Int): Boolean { + return columnIndex == 3 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/IconRenderer.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/IconRenderer.kt new file mode 100644 index 00000000..428cfc46 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/IconRenderer.kt @@ -0,0 +1,92 @@ +package com.smallcloud.refactai.aitoolbox.table.renderers + +import com.intellij.openapi.observable.util.whenMousePressed +import com.intellij.openapi.ui.putUserData +import com.smallcloud.refactai.aitoolbox.LongthinkKey +import com.smallcloud.refactai.aitoolbox.table.LongthinkTableModel +import com.smallcloud.refactai.aitoolbox.utils.getReasonForEntryFromState +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import javax.swing.* +import javax.swing.table.TableCellEditor +import javax.swing.table.TableCellRenderer + +open class IconRenderer( + val originalIcon: Icon, + var mousePressedFunction: () -> Unit = {}, + var toolTipText: String? = null +) : AbstractCellEditor(), TableCellEditor, TableCellRenderer { + private data class CacheEntry(val isSelected: Boolean, + val isEnabled: Boolean, + val foreground: Color) + + private var cachedIcons: MutableMap = mutableMapOf() + + override fun getTableCellRendererComponent( + table: JTable, value: Any?, + isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int + ): Component { + val component = JLabel() + val model = table.model as LongthinkTableModel + val entry = if (row < model.rowCount) model.elementAt(row) else return component + (component as JComponent).putUserData(LongthinkKey, entry) + component.isOpaque = true + component.icon = originalIcon + component.preferredSize = Dimension(component.icon.iconWidth + 4, 0) + component.horizontalAlignment = SwingConstants.CENTER + component.addPropertyChangeListener { + if (it.propertyName == "UI") { + cachedIcons.clear() + } + } + + if (value == null) return component + + val need: Boolean = when (value) { + is Boolean -> { + value + } + + is Int -> { + value != 0 + } + + else -> { + false + } + } + + if (isSelected) { + component.foreground = table.selectionForeground + component.background = table.selectionBackground + } else { + component.foreground = table.foreground + component.background = table.background + } + val reason = getReasonForEntryFromState(entry.getFunctionByFilter()) + val isEnabled = reason == null + val disabledForeground = UIManager.getColor("Label.disabledForeground") + + component.isEnabled = isEnabled + component.toolTipText = if (toolTipText != null) toolTipText else reason + component.icon = if (need) cachedIcons.getOrPut(CacheEntry(isSelected, isEnabled, + if (isEnabled) component.foreground else disabledForeground)) { + colorize(originalIcon, if (isEnabled) component.foreground else disabledForeground) + } else null + + return component + } + + override fun getCellEditorValue(): Any { + return "" + } + + override fun getTableCellEditorComponent(table: JTable, value: Any?, isSelected: Boolean, row: Int, column: Int): Component { + return getTableCellRendererComponent(table, value, true, true, row, column).apply { + whenMousePressed { + mousePressedFunction() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/LabelRenderer.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/LabelRenderer.kt new file mode 100644 index 00000000..a0308c56 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/LabelRenderer.kt @@ -0,0 +1,46 @@ +package com.smallcloud.refactai.aitoolbox.table.renderers + +import com.intellij.util.ui.JBUI +import com.smallcloud.refactai.aitoolbox.table.LongthinkTableModel +import com.smallcloud.refactai.aitoolbox.utils.getReasonForEntryFromState +import java.awt.Component +import java.awt.Font +import javax.swing.JLabel +import javax.swing.JTable +import javax.swing.table.TableCellRenderer + +internal class LabelRenderer : JLabel(), TableCellRenderer { + private var boldFont: Font? = null + init { + isOpaque = true + border = JBUI.Borders.empty(4, 10, 4, 0) + } + override fun getTableCellRendererComponent( + table: JTable?, value: Any?, + isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int + ): Component { + if (table == null) { + return this + } + if (boldFont == null) { + boldFont = table.font //JBFont.create(Font(font.family, Font.BOLD, table.font.size)) + } + if (isSelected) { + foreground = table.selectionForeground + background = table.selectionBackground + } else { + foreground = table.foreground + background = table.background + } + val model = table.model as LongthinkTableModel + val entry = if (row < model.rowCount) model.elementAt(row) else return this + font = if (entry.catchAllHighlight || entry.catchAllSelection) boldFont else table.font + text = value.toString() + + val longthink = model.elementAt(row) + val reason = getReasonForEntryFromState(longthink.functions.first()) + isEnabled = reason == null + toolTipText = reason + return this + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/LikeRenderer.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/LikeRenderer.kt new file mode 100644 index 00000000..225759eb --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/LikeRenderer.kt @@ -0,0 +1,72 @@ +package com.smallcloud.refactai.aitoolbox.table.renderers + +import com.smallcloud.refactai.Resources +import com.smallcloud.refactai.aitoolbox.table.LongthinkTableModel +import com.smallcloud.refactai.aitoolbox.utils.getReasonForEntryFromState +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import javax.swing.Icon +import javax.swing.JLabel +import javax.swing.JTable +import javax.swing.UIManager +import javax.swing.table.TableCellRenderer + +internal class LikeRenderer : JLabel(), TableCellRenderer { + private data class CacheEntry(val isLiked: Boolean, + val isSelected: Boolean, + val isEnabled: Boolean, + val foreground: Color) + + private var cachedIcons: MutableMap = mutableMapOf() + + init { + isOpaque = true + icon = Resources.Icons.LIKE_CHECKED_16x16 + text = "999+" + preferredSize = Dimension(icon.iconWidth + 4, 0) + horizontalAlignment = LEFT + addPropertyChangeListener { + if (it.propertyName == "UI") { + cachedIcons.clear() + } + } + } + + override fun getTableCellRendererComponent( + table: JTable, value: Any?, + isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int + ): Component { + if (value == null) return this + val model = table.model as LongthinkTableModel + val longthink = model.elementAt(row) + val reason = getReasonForEntryFromState(longthink.getFunctionByFilter()) + isEnabled = reason == null + toolTipText = reason + + val realValue = value as Pair<*, *> + val likes = realValue.first.toString() + val isLiked = realValue.second.toString().toBoolean() + + if (isSelected) { + foreground = table.selectionForeground + background = table.selectionBackground + } else { + foreground = table.foreground + background = table.background + } + + val disabledForeground = UIManager.getColor("Label.disabledForeground") + + icon = cachedIcons.getOrPut(CacheEntry(isLiked, isSelected, isEnabled, + if (isEnabled) foreground else disabledForeground)) { + colorize(if (isLiked) Resources.Icons.LIKE_CHECKED_16x16 else Resources.Icons.LIKE_UNCHECKED_16x16, + if (isEnabled) foreground else disabledForeground) + } + + text = if (likes.toInt() > 999) "999+" else likes + font = table.font + + return this + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/colorize.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/colorize.kt new file mode 100644 index 00000000..67eb90ca --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/table/renderers/colorize.kt @@ -0,0 +1,27 @@ +package com.smallcloud.refactai.aitoolbox.table.renderers + +import com.intellij.util.IconUtil +import java.awt.Color +import java.awt.image.RGBImageFilter +import javax.swing.Icon + + +private class FullColorizeFilter(val color: Color) : RGBImageFilter() { + override fun filterRGB(x: Int, y: Int, rgba: Int): Int { + val a = rgba shr 24 and 0xff + var r = rgba shr 16 and 0xff + var g = rgba shr 8 and 0xff + var b = rgba and 0xff + if (a != 0) { + r = color.red + g = color.green + b = color.blue + } + return a shl 24 or (r and 255 shl 16) or (g and 255 shl 8) or (b and 255) + } +} + + +internal fun colorize(originalIcon: Icon, foreground: Color): Icon { + return IconUtil.filterIcon(originalIcon, { FullColorizeFilter(foreground) }, null) +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/aitoolbox/utils/EntryReason.kt b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/utils/EntryReason.kt new file mode 100644 index 00000000..f46e7547 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/aitoolbox/utils/EntryReason.kt @@ -0,0 +1,60 @@ +package com.smallcloud.refactai.aitoolbox.utils + +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.smallcloud.refactai.RefactAIBundle +import com.smallcloud.refactai.aitoolbox.State +import com.smallcloud.refactai.privacy.Privacy +import com.smallcloud.refactai.privacy.PrivacyService +import com.smallcloud.refactai.struct.LongthinkFunctionEntry + +fun getFilteredIntent(intent: String): String { + var filteredIntent = intent + while (filteredIntent.isNotEmpty() && filteredIntent.last().isWhitespace()) { + filteredIntent = filteredIntent.dropLast(1) + } + return filteredIntent +} + +fun getReasonForEntryFromState(): String? { + return getReasonForEntryFromState(State.entry) +} + +fun getReasonForEntryFromState(entry: LongthinkFunctionEntry): String? { + if (State.editor == null) return null + val vFile = FileDocumentManager.getInstance().getFile(State.editor!!.document) + if (PrivacyService.instance.getPrivacy(vFile) == Privacy.DISABLED) { + return RefactAIBundle.message("aiToolbox.reasons.privacyDisabled") + } + if (entry.thirdParty && PrivacyService.instance.getPrivacy(vFile) < Privacy.THIRDPARTY) { + return RefactAIBundle.message("aiToolbox.reasons.thirdParty") + } + if (getFilteredIntent(entry.intent).endsWith("?")) return null + if (vFile != null && !entry.supportsLanguages.match(vFile.name)) { + return RefactAIBundle.message("aiToolbox.reasons.supportLang") + } + if (!State.haveSelection) { + if (!entry.supportHighlight) { + return RefactAIBundle.message("aiToolbox.reasons.selectCodeFirst", + entry.selectedLinesMin, entry.selectedLinesMax) + } +// if (State.currentIntent.isEmpty() && (entry.catchAny())) { +// return RefactAIBundle.message("aiToolbox.reasons.writeSomething") +// } + } else { + val lines = State.finishPosition.line - State.startPosition.line + 1 + if (!entry.supportSelection) { + return RefactAIBundle.message("aiToolbox.reasons.onlyForHL") + } + if (entry.selectedLinesMax < lines) { + return RefactAIBundle.message("aiToolbox.reasons.linesGreater", entry.selectedLinesMax) + } + if (entry.selectedLinesMin > lines) { + return RefactAIBundle.message("aiToolbox.reasons.linesLess", entry.selectedLinesMin) + } +// if (State.currentIntent.isEmpty() && (entry.catchAny())) { +// return RefactAIBundle.message("aiToolbox.reasons.writeSomething") +// } + } + + return null +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/io/InferenceGlobalContext.kt b/src/main/kotlin/com/smallcloud/refactai/io/InferenceGlobalContext.kt index a0835bf0..85d1f069 100644 --- a/src/main/kotlin/com/smallcloud/refactai/io/InferenceGlobalContext.kt +++ b/src/main/kotlin/com/smallcloud/refactai/io/InferenceGlobalContext.kt @@ -4,10 +4,9 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.util.concurrency.AppExecutorUtil import com.intellij.util.messages.MessageBus +import com.smallcloud.refactai.Resources import com.smallcloud.refactai.account.LoginStateService -import com.smallcloud.refactai.struct.DeploymentMode -import com.smallcloud.refactai.struct.SMCRequest -import com.smallcloud.refactai.struct.SMCRequestBody +import com.smallcloud.refactai.struct.* import java.net.URI import java.util.concurrent.Future import com.smallcloud.refactai.account.AccountManager.Companion.instance as AccountManager @@ -200,6 +199,17 @@ class InferenceGlobalContext : Disposable { return SMCRequest(LSPProcessHolder.url, requestData, apiKey ?: "self_hosted") } + @Deprecated("Will be removed in next release") + fun makeRequestOld(requestData: SMCRequestBodyOld): SMCRequestOld? { + val apiKey = AccountManager.apiKey +// if (apiKey.isNullOrEmpty() && isCloud) return null + + requestData.temperature = if (temperature != null) temperature!! else Resources.defaultTemperature + requestData.client = "${Resources.client}-${Resources.version}" + val uri = inferenceUri?.let { URI(it) } ?: cloudInferenceUri ?: return null + return SMCRequestOld(uri, requestData, apiKey ?: "self_hosted") + } + override fun dispose() { lastTask?.cancel(true) reconnectScheduler.shutdown() diff --git a/src/main/kotlin/com/smallcloud/refactai/io/RequestHelpers.kt b/src/main/kotlin/com/smallcloud/refactai/io/RequestHelpers.kt index bf2e1543..3a99af19 100644 --- a/src/main/kotlin/com/smallcloud/refactai/io/RequestHelpers.kt +++ b/src/main/kotlin/com/smallcloud/refactai/io/RequestHelpers.kt @@ -3,9 +3,7 @@ package com.smallcloud.refactai.io import com.google.gson.Gson import com.google.gson.JsonObject import com.smallcloud.refactai.account.AccountManager -import com.smallcloud.refactai.struct.SMCExceptions -import com.smallcloud.refactai.struct.SMCRequest -import com.smallcloud.refactai.struct.SMCStreamingPeace +import com.smallcloud.refactai.struct.* import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import com.smallcloud.refactai.io.InferenceGlobalContext.Companion.instance as InferenceGlobalContext @@ -42,6 +40,38 @@ private fun lookForCommonErrors(json: JsonObject, request: SMCRequest): String? } return null } +@Deprecated("Will be removed in next release") +private fun lookForCommonErrors(json: JsonObject, request: SMCRequestOld): String? { + if (json.has("detail")) { + val gson = Gson() + val detail = gson.toJson(json.get("detail")) + UsageStats.addStatistic(false, request.stat, request.uri.toString(), detail) + return detail + } + if (json.has("retcode") && json.get("retcode").asString != "OK") { + UsageStats.addStatistic( + false, request.stat, + request.uri.toString(), json.get("human_readable_message").asString + ) + return json.get("human_readable_message").asString + } + if (json.has("status") && json.get("status").asString == "error") { + UsageStats.addStatistic( + false, request.stat, + request.uri.toString(), json.get("human_readable_message").asString + ) + return json.get("human_readable_message").asString + } + if (json.has("error")) { + UsageStats.addStatistic( + false, request.stat, + request.uri.toString(), json.get("error").asJsonObject.get("message").asString + ) + return json.get("error").asJsonObject.get("message").asString + } + return null +} + fun streamedInferenceFetch( request: SMCRequest, @@ -84,38 +114,41 @@ fun streamedInferenceFetch( return job } -fun inferenceFetch( - request: SMCRequest, - dataReceiveEnded: (SMCStreamingPeace) -> Unit, +@Deprecated("Will be removed in next release") +fun streamedInferenceFetchOld( + request: SMCRequestOld, + dataReceiveEnded: (String) -> Unit = {}, + dataReceived: (data: SMCPrediction) -> Unit = {}, ): CompletableFuture>? { val gson = Gson() val uri = request.uri val body = gson.toJson(request.body) val headers = mapOf( - "Authorization" to "Bearer ${request.token}", + "Authorization" to "Bearer ${request.token}", ) - if (InferenceGlobalContext.status == ConnectionStatus.DISCONNECTED) return null + if (InferenceGlobalContext.status == ConnectionStatus.DISCONNECTED || !LSPProcessHolder.lspIsWorking) return null val job = InferenceGlobalContext.connection.post( - uri, body, headers, - stat = request.stat, - dataReceiveEnded = { - val rawJson = gson.fromJson(it, JsonObject::class.java) - if (rawJson.has("metering_balance")) { - AccountManager.instance.meteringBalance = rawJson.get("metering_balance").asInt - } + uri, body, headers, + stat = request.stat, + dataReceiveEnded = dataReceiveEnded, + dataReceived = {body: String, reqId: String -> + val rawJson = gson.fromJson(body, JsonObject::class.java) + if (rawJson.has("metering_balance")) { + AccountManager.instance.meteringBalance = rawJson.get("metering_balance").asInt + } - val json = gson.fromJson(it, SMCStreamingPeace::class.java) - InferenceGlobalContext.lastAutoModel = json.model - UsageStats.addStatistic(true, request.stat, request.uri.toString(), "") - dataReceiveEnded(json) - }, - errorDataReceived = { - lookForCommonErrors(it, request)?.let { message -> - throw SMCExceptions(message) - } + val json = gson.fromJson(body, SMCPrediction::class.java) + InferenceGlobalContext.lastAutoModel = json.model + UsageStats.addStatistic(true, request.stat, request.uri.toString(), "") + dataReceived(json) + }, + errorDataReceived = { + lookForCommonErrors(it, request)?.let { message -> + throw SMCExceptions(message) } + } ) return job diff --git a/src/main/kotlin/com/smallcloud/refactai/listeners/AIToolboxInvokeAction.kt b/src/main/kotlin/com/smallcloud/refactai/listeners/AIToolboxInvokeAction.kt new file mode 100644 index 00000000..3c0f0125 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/listeners/AIToolboxInvokeAction.kt @@ -0,0 +1,45 @@ +package com.smallcloud.refactai.listeners + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.util.Key +import com.smallcloud.refactai.modes.ModeProvider.Companion.getOrCreateModeProvider +import com.smallcloud.refactai.privacy.ActionUnderPrivacy +import com.smallcloud.refactai.struct.LongthinkFunctionEntry + +class AIToolboxInvokeAction: ActionUnderPrivacy() { + + private fun getEditor(e: AnActionEvent): Editor? { + return CommonDataKeys.EDITOR.getData(e.dataContext) + ?: e.presentation.getClientProperty(Key(CommonDataKeys.EDITOR.name)) + } + override fun actionPerformed(e: AnActionEvent) { + val editor: Editor = getEditor(e) ?: return + doActionPerformed(editor) + } + + fun doActionPerformed(editor: Editor, entryFromContext: LongthinkFunctionEntry? = null) { + if (!editor.document.isWritable) return + if (getOrCreateModeProvider(editor).getDiffMode().isInRenderState() || + getOrCreateModeProvider(editor).getHighlightMode().isInRenderState()) + return + + val entry = entryFromContext?.copy() + if (entryFromContext != null && entry != null) { + entry.intent = entryFromContext.modelFixedIntent + } + + if (getOrCreateModeProvider(editor).getDiffMode().isInActiveState() || + editor.selectionModel.selectionStart != editor.selectionModel.selectionEnd) { + getOrCreateModeProvider(editor).getDiffMode().actionPerformed(editor, entryFromContext=entry) + } else { + getOrCreateModeProvider(editor).getHighlightMode().actionPerformed(editor, entryFromContext=entry) + } + } + + override fun setup(e: AnActionEvent) { + e.presentation.isEnabled = getEditor(e) != null + isEnabledInModalContext = getEditor(e) != null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/listeners/DiffActionPromoter.kt b/src/main/kotlin/com/smallcloud/refactai/listeners/DiffActionPromoter.kt new file mode 100644 index 00000000..857c05d5 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/listeners/DiffActionPromoter.kt @@ -0,0 +1,16 @@ +package com.smallcloud.refactai.listeners + +import com.intellij.openapi.actionSystem.ActionPromoter +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.smallcloud.refactai.aitoolbox.ToolboxPaneInvokeAction + +class DiffActionsPromoter : ActionPromoter { + override fun promote(actions: MutableList, context: DataContext): MutableList { + val editor = CommonDataKeys.EDITOR.getData(context) + + if (editor == null || !editor.document.isWritable) return actions.toMutableList() + return actions.filterIsInstance().toMutableList() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/listeners/LastEditorGetterListener.kt b/src/main/kotlin/com/smallcloud/refactai/listeners/LastEditorGetterListener.kt index 57ed8f47..1b4fffcc 100644 --- a/src/main/kotlin/com/smallcloud/refactai/listeners/LastEditorGetterListener.kt +++ b/src/main/kotlin/com/smallcloud/refactai/listeners/LastEditorGetterListener.kt @@ -48,6 +48,7 @@ class LastEditorGetterListener : EditorFactoryListener, FileEditorManagerListene ApplicationManager.getApplication() .messageBus.connect(this) .subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, this) + instance = this } private fun setup(editor: Editor) { @@ -81,6 +82,7 @@ class LastEditorGetterListener : EditorFactoryListener, FileEditorManagerListene override fun dispose() {} companion object { + lateinit var instance: LastEditorGetterListener var LAST_EDITOR: Editor? = null } } diff --git a/src/main/kotlin/com/smallcloud/refactai/listeners/QuickLongthinkActions.kt b/src/main/kotlin/com/smallcloud/refactai/listeners/QuickLongthinkActions.kt new file mode 100644 index 00000000..76d6d1ef --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/listeners/QuickLongthinkActions.kt @@ -0,0 +1,113 @@ +package com.smallcloud.refactai.listeners + +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.DumbAwareAction +import com.smallcloud.refactai.Resources +import com.smallcloud.refactai.aitoolbox.filter +import com.smallcloud.refactai.panes.gptchat.ChatGPTPaneInvokeAction +import com.smallcloud.refactai.privacy.ActionUnderPrivacy +import com.smallcloud.refactai.struct.LongthinkFunctionEntry +import com.smallcloud.refactai.struct.LongthinkFunctionVariation +import com.smallcloud.refactai.aitoolbox.LongthinkFunctionProvider.Companion.instance as LongthinkFunctionProvider +import com.smallcloud.refactai.io.InferenceGlobalContext.Companion.instance as InferenceGlobalContext + +class QuickLongthinkAction( + var function: LongthinkFunctionVariation = QuickLongthinkActionsService.DUMMY_LONGTHINK, + var hlFunction: LongthinkFunctionVariation = QuickLongthinkActionsService.DUMMY_LONGTHINK, + ): ActionUnderPrivacy() { + override fun setup(e: AnActionEvent) { + val editor = CommonDataKeys.EDITOR.getData(e.dataContext) ?: return + val hl = editor.selectionModel.selectedText == null + + val variation = (if (hl) hlFunction else function) + val entry = variation.getFunctionByFilter() + e.presentation.text = entry.label + e.presentation.icon = Resources.Icons.LOGO_RED_16x16 + e.presentation.isEnabledAndVisible = variation != QuickLongthinkActionsService.DUMMY_LONGTHINK + } + + override fun actionPerformed(e: AnActionEvent) { + val editor = CommonDataKeys.EDITOR.getData(e.dataContext) ?: return + val hl = editor.selectionModel.selectedText == null + AIToolboxInvokeAction().doActionPerformed(editor, + if (hl) hlFunction.getFunctionByFilter() else function.getFunctionByFilter()) + } +} + +class AskChatAction: DumbAwareAction(Resources.Icons.LOGO_RED_16x16) { + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = InferenceGlobalContext.isCloud + e.presentation.text = "Ask in Chat..." + } + + override fun actionPerformed(e: AnActionEvent) { + ChatGPTPaneInvokeAction().doActionPerformed(true) + } +} + + +class QuickLongthinkActionsService { + private val groups = mutableListOf() + private val actions = MutableList(TOP_N) { QuickLongthinkAction() } + init { + for (groupName in listOf("EditorPopupMenu")) { + var group = ActionManager.getInstance().getAction(groupName) ?: continue + group = group as DefaultActionGroup + groups.add(group) + } + } + + fun recreateActions() { + groups.forEach { group -> + actions.forEach { action -> + if (group.containsAction(action)) { + group.remove(action) + } + } + } + var filteredLTFunctions = filter(LongthinkFunctionProvider.functionVariations, "", true) + filteredLTFunctions = filteredLTFunctions.filter { !it.catchAny() && it.supportSelection } + var realL = minOf(TOP_N, filteredLTFunctions.size) + filteredLTFunctions = filteredLTFunctions.subList(0, minOf(TOP_N, filteredLTFunctions.size)) + if (TOP_N > realL) { + filteredLTFunctions = filteredLTFunctions.toMutableList().apply { + repeat(TOP_N - realL) { + this.add(DUMMY_LONGTHINK) + } + }.toList() + } + + var filteredHLFunctions = filter(LongthinkFunctionProvider.functionVariations, "", false) + filteredHLFunctions = filteredHLFunctions.filter { !it.catchAny() && it.supportHighlight } + realL = minOf(TOP_N, filteredHLFunctions.size) + filteredHLFunctions = filteredHLFunctions.subList(0, realL) + if (TOP_N > realL) { + filteredHLFunctions = filteredHLFunctions.toMutableList().apply { + repeat(TOP_N - realL) { + this.add(DUMMY_LONGTHINK) + } + }.toList() + } + + filteredLTFunctions.zip(filteredHLFunctions).zip(actions).forEach { (functions, action) -> + action.function = functions.first + action.hlFunction = functions.second + } + groups.forEach { group -> + actions.reversed().forEach { action -> + if (action.function.label.isNotEmpty()) { + group.addAction(action, Constraints(Anchor.FIRST, "RefactAIAskChat")) + } + } + } + } + + companion object { + const val TOP_N = 3 + val DUMMY_LONGTHINK = LongthinkFunctionVariation(listOf(LongthinkFunctionEntry()), listOf("")) + @JvmStatic + val instance: QuickLongthinkActionsService + get() = ApplicationManager.getApplication().getService(QuickLongthinkActionsService::class.java) + } +} diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/ModeProvider.kt b/src/main/kotlin/com/smallcloud/refactai/modes/ModeProvider.kt index 6e388873..11447ade 100644 --- a/src/main/kotlin/com/smallcloud/refactai/modes/ModeProvider.kt +++ b/src/main/kotlin/com/smallcloud/refactai/modes/ModeProvider.kt @@ -21,6 +21,8 @@ import com.smallcloud.refactai.listeners.GlobalCaretListener import com.smallcloud.refactai.listeners.GlobalFocusListener import com.smallcloud.refactai.modes.completion.CompletionMode import com.smallcloud.refactai.modes.completion.structs.DocumentEventExtra +import com.smallcloud.refactai.modes.diff.DiffMode +import com.smallcloud.refactai.modes.highlight.HighlightMode import com.smallcloud.refactai.statistic.UsageStatistic import com.smallcloud.refactai.statistic.UsageStats import java.lang.System.currentTimeMillis @@ -32,6 +34,8 @@ import com.smallcloud.refactai.io.InferenceGlobalContext.Companion.instance as I enum class ModeType { Completion, + Diff, + Highlight } @@ -39,6 +43,8 @@ class ModeProvider( private val editor: Editor, private val modes: Map = mapOf( ModeType.Completion to CompletionMode(), + ModeType.Diff to DiffMode(), + ModeType.Highlight to HighlightMode() ), private var activeMode: Mode? = null, private val pluginState: PluginState = PluginState.instance, @@ -103,8 +109,13 @@ class ModeProvider( fun isInCompletionMode(): Boolean = activeMode === modes[ModeType.Completion] + fun isInDiffMode(): Boolean = activeMode === modes[ModeType.Diff] + fun isInHighlightMode(): Boolean = activeMode === modes[ModeType.Highlight] fun getCompletionMode(): Mode = modes[ModeType.Completion]!! + fun getDiffMode(): DiffMode = (modes[ModeType.Diff] as DiffMode?)!! + fun getHighlightMode(): HighlightMode = (modes[ModeType.Highlight] as HighlightMode?)!! + fun switchMode(newMode: ModeType = ModeType.Completion) { if (activeMode == modes[newMode]) return activeMode?.cleanup(editor) diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/completion/prompt/RequestCreator.kt b/src/main/kotlin/com/smallcloud/refactai/modes/completion/prompt/RequestCreator.kt index a3003188..9d15a05d 100644 --- a/src/main/kotlin/com/smallcloud/refactai/modes/completion/prompt/RequestCreator.kt +++ b/src/main/kotlin/com/smallcloud/refactai/modes/completion/prompt/RequestCreator.kt @@ -1,14 +1,76 @@ package com.smallcloud.refactai.modes.completion.prompt - import com.smallcloud.refactai.Resources import com.smallcloud.refactai.statistic.UsageStatistic -import com.smallcloud.refactai.struct.SMCCursor -import com.smallcloud.refactai.struct.SMCInputs -import com.smallcloud.refactai.struct.SMCRequest -import com.smallcloud.refactai.struct.SMCRequestBody +import com.smallcloud.refactai.struct.* import com.smallcloud.refactai.io.InferenceGlobalContext.Companion.instance as InferenceGlobalContext import com.smallcloud.refactai.lsp.LSPProcessHolder.Companion.instance as LSPProcessHolder +@Deprecated("Will be removed in next release") +object RequestCreatorOld { + private const val symbolsBudget: Long = 5_000 + private const val distanceThreshold: Double = 0.75 + + fun create( + fileName: String, text: String, + startOffset: Int, endOffset: Int, + stat: UsageStatistic, + intent: String, functionName: String, + promptInfo: List, + model: String, + stream: Boolean = true, + ): SMCRequestOld? { + var currentBudget = symbolsBudget + val sources = mutableMapOf(fileName to text) + val poi = mutableListOf() + promptInfo + .filter { it.fileInfo.isOpened() } + .filter { it.distance < distanceThreshold } + .sortedByDescending { it.fileInfo.lastEditorShown } + .forEach { + if ((currentBudget - it.prompt.length) <= 0) return@forEach + if (sources.containsKey(it.fileName)) return@forEach + sources[it.fileName] = it.text + val cursors = it.cursors() + poi.add(POI(it.fileName, cursors.first, cursors.second, 1.0 - it.distance)) + currentBudget -= it.prompt.length + } + promptInfo + .filter { !it.fileInfo.isOpened() } + .filter { it.distance < distanceThreshold } + .sortedByDescending { it.fileInfo.lastUpdatedTs } + .forEach { + if ((currentBudget - it.prompt.length) <= 0) return@forEach + if (sources.containsKey(it.fileName)) return@forEach + sources[it.fileName] = it.text + val cursors = it.cursors() + poi.add(POI(it.fileName, cursors.first, cursors.second, 1.0 - it.distance)) + currentBudget -= it.prompt.length + } + + val requestBody = SMCRequestBodyOld( + sources = sources, + intent = intent, + functionName = functionName, + cursorFile = fileName, + cursor0 = startOffset, + cursor1 = endOffset, + maxTokens = 50, + maxEdits = 1, + stopTokens = listOf("\n\n"), + stream = stream, + poi = poi, + model = model + ) + + return InferenceGlobalContext.makeRequestOld( + requestBody, + )?.also { + it.stat = stat + it.uri = it.uri.resolve(Resources.defaultContrastUrlSuffix) + } + } +} + object RequestCreator { fun create( fileName: String, text: String, diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/diff/DiffLayout.kt b/src/main/kotlin/com/smallcloud/refactai/modes/diff/DiffLayout.kt new file mode 100644 index 00000000..775f49cf --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/modes/diff/DiffLayout.kt @@ -0,0 +1,97 @@ +package com.smallcloud.refactai.modes.diff + +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.util.Disposer +import com.smallcloud.refactai.modes.diff.renderer.Inlayer +import com.smallcloud.refactai.struct.SMCRequestOld +import dev.gitlive.difflib.patch.DeltaType +import dev.gitlive.difflib.patch.Patch + +class DiffLayout( + private val editor: Editor, + val request: SMCRequestOld, +) : Disposable { + private var inlayer: Inlayer = Inlayer(editor, request.body.intent) + private var blockEvents: Boolean = false + private var lastPatch = Patch() + var rendered: Boolean = false + + override fun dispose() { + rendered = false + blockEvents = false + inlayer.dispose() + } + + private fun getOffsetFromStringNumber(stringNumber: Int, column: Int = 0): Int { + return getOffsetFromStringNumber(editor, stringNumber, column) + } + + fun update(patch: Patch): DiffLayout { + assert(!rendered) { "Already rendered" } + try { + blockEvents = true + editor.document.startGuardedBlockChecking() + lastPatch = patch + inlayer.update(patch) + rendered = true + } catch (ex: Exception) { + Disposer.dispose(this) + throw ex + } finally { + editor.document.stopGuardedBlockChecking() + blockEvents = false + } + return this + } + + fun cancelPreview() { + Disposer.dispose(this) + } + + fun applyPreview() { + try { + applyPreviewInternal() + } catch (e: Throwable) { + Logger.getInstance(javaClass).warn("Failed in the processes of accepting completion", e) + } finally { + Disposer.dispose(this) + } + } + + private fun applyPreviewInternal() { + val document = editor.document + for (det in lastPatch.getDeltas().sortedByDescending { it.source.position }) { + if (det.target.lines == null) continue + when (det.type) { + DeltaType.INSERT -> { + document.insertString( + getOffsetFromStringNumber(det.source.position), + det.target.lines!!.joinToString("\n", postfix = "\n") + ) + } + + DeltaType.CHANGE -> { + document.deleteString( + getOffsetFromStringNumber(det.source.position), + getOffsetFromStringNumber(det.source.position + det.source.size()) + ) + document.insertString( + getOffsetFromStringNumber(det.source.position), + det.target.lines!!.joinToString("\n", postfix = "\n") + ) + } + + DeltaType.DELETE -> { + document.deleteString( + getOffsetFromStringNumber(det.source.position), + getOffsetFromStringNumber(det.source.position + det.source.size()) + ) + } + + else -> {} + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/diff/DiffMode.kt b/src/main/kotlin/com/smallcloud/refactai/modes/diff/DiffMode.kt new file mode 100644 index 00000000..a9bc65dc --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/modes/diff/DiffMode.kt @@ -0,0 +1,255 @@ +package com.smallcloud.refactai.modes.diff + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.editor.event.CaretEvent +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.util.concurrency.AppExecutorUtil +import com.smallcloud.refactai.Resources +import com.smallcloud.refactai.io.ConnectionStatus +import com.smallcloud.refactai.io.streamedInferenceFetchOld +import com.smallcloud.refactai.modes.Mode +import com.smallcloud.refactai.modes.ModeProvider.Companion.getOrCreateModeProvider +import com.smallcloud.refactai.modes.ModeType +import com.smallcloud.refactai.modes.completion.prompt.RequestCreatorOld +import com.smallcloud.refactai.modes.completion.structs.DocumentEventExtra +import com.smallcloud.refactai.modes.highlight.HighlightContext +import com.smallcloud.refactai.statistic.UsageStatistic +import com.smallcloud.refactai.struct.LongthinkFunctionEntry +import com.smallcloud.refactai.struct.SMCRequestOld +import com.smallcloud.refactai.utils.getExtension +import dev.gitlive.difflib.DiffUtils +import dev.gitlive.difflib.patch.Patch +import java.util.concurrent.CancellationException +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import com.smallcloud.refactai.io.InferenceGlobalContext.Companion.instance as InferenceGlobalContext + +class DiffMode( + override var needToRender: Boolean = true +) : Mode { + private val scope: String = "query_diff" + private val logger = Logger.getInstance(DiffMode::class.java) + private val scheduler = AppExecutorUtil.createBoundedScheduledExecutorService("SMCDiffScheduler", 2) + private val app = ApplicationManager.getApplication() + private var diffLayout: DiffLayout? = null + private var processTask: Future<*>? = null + private var renderTask: Future<*>? = null + private var needRainbowAnimation: Boolean = false + private var lastFromHL: Boolean = false + + private fun isProgress(): Boolean { + return needRainbowAnimation + } + + private fun finishRenderRainbow() { + needRainbowAnimation = false +// if (!renderTask?.isDone!! || !renderTask?.isCancelled!!) +// renderTask?.get() + } + + private fun cancel(editor: Editor?) { + try { + processTask?.cancel(true) + processTask?.get() + } catch (_: CancellationException) { + } finally { + if (InferenceGlobalContext.status != ConnectionStatus.DISCONNECTED && + InferenceGlobalContext.status != ConnectionStatus.ERROR + ) { + InferenceGlobalContext.status = ConnectionStatus.CONNECTED + } + app.invokeLater { + finishRenderRainbow() + diffLayout?.cancelPreview() + diffLayout = null + } + if (editor != null && !Thread.currentThread().stackTrace.any { it.methodName == "switchMode" }) { + getOrCreateModeProvider(editor).switchMode() + } + } + } + + override fun beforeDocumentChangeNonBulk(event: DocumentEventExtra) { + cancel(event.editor) + } + + override fun onTextChange(event: DocumentEventExtra) { + } + + override fun onTabPressed(editor: Editor, caret: Caret?, dataContext: DataContext) { + diffLayout?.applyPreview() + diffLayout = null + editor.putUserData(Resources.ExtraUserDataKeys.addedFromHL, lastFromHL) + if (lastFromHL) { + getOrCreateModeProvider(editor).getHighlightMode().actionPerformed(editor) + } else { + getOrCreateModeProvider(editor).switchMode() + } + } + + override fun onEscPressed(editor: Editor, caret: Caret?, dataContext: DataContext) { + cancel(editor) + lastFromHL = false + } + + override fun onCaretChange(event: CaretEvent) {} + + fun isInRenderState(): Boolean { + return (diffLayout != null && !diffLayout!!.rendered) || + (renderTask != null && !renderTask!!.isDone && !renderTask!!.isCancelled) || isProgress() + } + + override fun isInActiveState(): Boolean { + return isInRenderState() || + (processTask != null && !processTask!!.isDone && !processTask!!.isCancelled) || + diffLayout != null + } + + override fun show() { + TODO("Not yet implemented") + } + + override fun hide() { + TODO("Not yet implemented") + } + + override fun cleanup(editor: Editor) { + cancel(editor) + } + + fun actionPerformed(editor: Editor, highlightContext: HighlightContext? = null, + entryFromContext: LongthinkFunctionEntry? = null) { + lastFromHL = highlightContext != null + val fileName = getActiveFile(editor.document) ?: return + val selectionModel = editor.selectionModel + var startSelectionOffset: Int = selectionModel.selectionStart + var endSelectionOffset: Int = selectionModel.selectionEnd + val startPosition: LogicalPosition + val finishPosition: LogicalPosition + val request: SMCRequestOld + if (InferenceGlobalContext.status == ConnectionStatus.DISCONNECTED) return + if (diffLayout == null || highlightContext != null) { + val entry: LongthinkFunctionEntry = highlightContext?.entry ?: entryFromContext ?: return + var funcName = (if (lastFromHL) entry.functionHlClick else entry.functionSelection) + if (funcName.isNullOrEmpty()) { + funcName = entry.functionName + } + + val stat = UsageStatistic(scope, entry.functionName, extension = getExtension(fileName)) + request = RequestCreatorOld.create( + fileName, editor.document.text, + startSelectionOffset, endSelectionOffset, + stat, entry.intent, funcName, listOf(), + model = InferenceGlobalContext.longthinkModel ?: entry.model ?: InferenceGlobalContext.model ?: Resources.defaultModel, + ) ?: return + startPosition = editor.offsetToLogicalPosition(startSelectionOffset) + finishPosition = editor.offsetToLogicalPosition(endSelectionOffset - 1) + selectionModel.removeSelection() + editor.contentComponent.requestFocus() + getOrCreateModeProvider(editor).switchMode(ModeType.Diff) + } else { + val lastDiffLayout = diffLayout ?: return + request = lastDiffLayout.request + startPosition = editor.offsetToLogicalPosition(request.body.cursor0) + finishPosition = editor.offsetToLogicalPosition(request.body.cursor1) + lastDiffLayout.cancelPreview() + diffLayout = null + } + + needRainbowAnimation = true + renderTask = scheduler.submit { + waitingDiff( + editor, + startPosition, finishPosition, + this::isProgress + ) + } + processTask = scheduler.submit { + process(request, editor) + } + } + + fun process( + request: SMCRequestOld, + editor: Editor + ) { + request.body.stopTokens = listOf() + request.body.maxTokens = 550 + request.body.maxEdits = if (request.body.functionName == "diff-atcursor") 1 else 10 + + InferenceGlobalContext.status = ConnectionStatus.PENDING + + var lastPatch: Patch? = null + streamedInferenceFetchOld(request, dataReceiveEnded = { + finishRenderRainbow() + if (lastPatch == null) return@streamedInferenceFetchOld + diffLayout = DiffLayout(editor, request) + app.invokeLater { + diffLayout?.update(lastPatch!!) + } + + InferenceGlobalContext.status = ConnectionStatus.CONNECTED + InferenceGlobalContext.lastErrorMsg = null + }) { prediction -> + if (prediction.status == null || prediction.status == "error") { + InferenceGlobalContext.status = ConnectionStatus.ERROR + InferenceGlobalContext.lastErrorMsg = "Parameters are not correct" + return@streamedInferenceFetchOld + } + + val predictedText = prediction.choices.firstOrNull()?.filesHeadMidTail?.get(request.body.cursorFile)?.mid + val finishReason = prediction.choices.firstOrNull()?.finishReason + if (predictedText == null || finishReason == null) { + return@streamedInferenceFetchOld + } + + lastPatch = request.body.sources[request.body.cursorFile]?.let { originText -> + prediction.choices.firstOrNull()?.filesHeadMidTail?.get(request.body.cursorFile)?.let { headMidTail -> + val newText = originText.replaceRange(headMidTail.head, + originText.length - headMidTail.tail, headMidTail.mid) + DiffUtils.diff( + originText.split('\n'), + newText.split('\n'), + ) + } + } + }?.also { + var requestFuture: Future<*>? = null + try { + requestFuture = it.get() + requestFuture.get() + logger.debug("Diff request finished") + } catch (_: InterruptedException) { + requestFuture?.cancel(true) + finishRenderRainbow() + getOrCreateModeProvider(editor).switchMode() + } catch (e: ExecutionException) { + catchNetExceptions(e.cause) + getOrCreateModeProvider(editor).switchMode() + } catch (e: Exception) { + InferenceGlobalContext.status = ConnectionStatus.ERROR + InferenceGlobalContext.lastErrorMsg = e.message + logger.warn("Exception while diff request processing", e) + getOrCreateModeProvider(editor).switchMode() + } + } + } + + private fun catchNetExceptions(e: Throwable?) { + InferenceGlobalContext.status = ConnectionStatus.ERROR + InferenceGlobalContext.lastErrorMsg = e?.message + logger.warn("Exception while diff request processing", e) + } + + private fun getActiveFile(document: Document): String? { + if (!app.isDispatchThread) return null + val file = FileDocumentManager.getInstance().getFile(document) + return file?.presentableName + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/diff/Utils.kt b/src/main/kotlin/com/smallcloud/refactai/modes/diff/Utils.kt new file mode 100644 index 00000000..75b1fb2f --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/modes/diff/Utils.kt @@ -0,0 +1,8 @@ +package com.smallcloud.refactai.modes.diff + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition + +fun getOffsetFromStringNumber(editor: Editor, stringNumber: Int, column: Int = 0): Int { + return editor.logicalPositionToOffset(LogicalPosition(maxOf(stringNumber, 0), column)) +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/BlockRenderer.kt b/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/BlockRenderer.kt new file mode 100644 index 00000000..eab936b7 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/BlockRenderer.kt @@ -0,0 +1,82 @@ +package com.smallcloud.refactai.modes.diff.renderer + + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorCustomElementRenderer +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.markup.TextAttributes +import dev.gitlive.difflib.patch.Patch +import java.awt.Color +import java.awt.Graphics +import java.awt.Rectangle + + +open class BlockElementRenderer( + private val color: Color, + private val veryColor: Color, + private val editor: Editor, + private val blockText: List, + private val smallPatches: List>, + private val deprecated: Boolean +) : EditorCustomElementRenderer { + + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + val line = blockText.maxByOrNull { it.length } + return editor.contentComponent + .getFontMetrics(RenderHelper.getFont(editor, deprecated)).stringWidth(line!!) + } + + override fun calcHeightInPixels(inlay: Inlay<*>): Int { + return editor.lineHeight * blockText.size + } + + override fun paint( + inlay: Inlay<*>, + g: Graphics, + targetRegion: Rectangle, + textAttributes: TextAttributes + ) { + val highlightG = g.create() + highlightG.color = color + highlightG.fillRect(targetRegion.x, targetRegion.y, 9999999, targetRegion.height) + g.font = RenderHelper.getFont(editor, deprecated) + g.color = editor.colorsScheme.defaultForeground + val metric = g.getFontMetrics(g.font) + + val smallPatchesG = g.create() + smallPatchesG.color = veryColor + smallPatches.withIndex().forEach { (i, patch) -> + val currentLine = blockText[i] + patch.getDeltas().forEach { + val startBound = g.font.getStringBounds( + currentLine.substring(0, it.target.position), + metric.fontRenderContext + ) + val endBound = g.font.getStringBounds( + currentLine.substring(0, it.target.position + it.target.size()), + metric.fontRenderContext + ) + smallPatchesG.fillRect( + targetRegion.x + startBound.width.toInt(), + targetRegion.y + i * editor.lineHeight, + (endBound.width - startBound.width).toInt(), + editor.lineHeight + ) + } + } + blockText.withIndex().forEach { (i, line) -> + g.drawString( + line, + 0, + targetRegion.y + i * editor.lineHeight + editor.ascent + ) + } + } +} + +class InsertBlockElementRenderer( + private val editor: Editor, + private val blockText: List, + private val smallPatches: List>, + private val deprecated: Boolean +) : BlockElementRenderer(greenColor, veryGreenColor, editor, blockText, smallPatches, deprecated) diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/Inlayer.kt b/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/Inlayer.kt new file mode 100644 index 00000000..60ddcbb5 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/Inlayer.kt @@ -0,0 +1,203 @@ +package com.smallcloud.refactai.modes.diff.renderer + +import com.intellij.ide.DataManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.TextRange +import com.intellij.util.text.findTextRange +import com.smallcloud.refactai.listeners.AIToolboxInvokeAction +import com.smallcloud.refactai.listeners.CancelPressedAction +import com.smallcloud.refactai.listeners.TabPressedAction +import com.smallcloud.refactai.modes.diff.getOffsetFromStringNumber +import dev.gitlive.difflib.DiffUtils +import dev.gitlive.difflib.patch.DeltaType +import dev.gitlive.difflib.patch.Patch + + +class Inlayer(val editor: Editor, private val intent: String) : Disposable { + private var inlays: MutableList> = mutableListOf() + private var renderers: MutableList = mutableListOf() + private var rangeHighlighters: MutableList = mutableListOf() + private fun getOffsetFromStringNumber(stringNumber: Int, column: Int = 0): Int { + return getOffsetFromStringNumber(editor, stringNumber, column) + } + + override fun dispose() { + inlays.forEach { it.dispose() } + inlays.clear() + + renderers.forEach { it.dispose() } + renderers.clear() + + rangeHighlighters.forEach { editor.markupModel.removeHighlighter(it) } + rangeHighlighters.clear() + } + + private fun renderInsertBlock(lines: List, smallPatches: List>, offset: Int) { + val logicalPosition = editor.offsetToLogicalPosition(offset) + val renderer = InsertBlockElementRenderer(editor, lines, smallPatches, false) + val isAbove = (logicalPosition.line < 1) + val element = editor + .inlayModel + .addBlockElement(offset, false, isAbove, if (isAbove) 1 else 998, renderer) + element?.let { + Disposer.register(this, it) + inlays.add(element) + } + } + + private fun findAlignment(str: String): String { + val splited = str.split(Regex("\\s+")) + if (splited.size < 2 || splited[0] != "") return "" + return str.substring(0, str.findTextRange(splited[1])!!.startOffset) + } + + private fun renderPanel(msg: String, offset: Int) { + val logicalPosition = editor.offsetToLogicalPosition(offset) + val alignment = findAlignment( + editor.document.getText( + TextRange( + editor.document.getLineStartOffset(logicalPosition.line), + editor.document.getLineEndOffset(logicalPosition.line) + ) + ) + ) + val firstSymbolPos = + editor.offsetToXY(editor.document.getLineStartOffset(logicalPosition.line) + alignment.length) + val context = DataManager.getInstance().getDataContext(editor.contentComponent) + val renderer = PanelRenderer(firstSymbolPos, editor, listOf( + "${getAcceptSymbol()} Approve (Tab)" to { TabPressedAction().actionPerformed(editor, context) }, + "${getRejectSymbol()} Reject (ESC)" to { CancelPressedAction().actionPerformed(editor, context) }, + "${getRerunSymbol()} Rerun \"${msg}\" (F1)" to { + val action = AIToolboxInvokeAction() + val event = AnActionEvent.createFromAnAction(action, null, ActionPlaces.UNKNOWN, context) + ActionUtil.performActionDumbAwareWithCallbacks(action, event) + } + )) + editor.inlayModel + .addBlockElement(offset, false, true, 1, renderer) + ?.also { + Disposer.register(this, it) + Disposer.register(it, renderer) + inlays.add(it) + renderers.add(renderer) + } + } + + fun update(patch: Patch): Inlayer { + val sortedDeltas = patch.getDeltas().sortedBy { it.source.position } + val offset: Int = if (sortedDeltas.isNotEmpty()) { + if (sortedDeltas.first().type == DeltaType.INSERT) { + getOffsetFromStringNumber(sortedDeltas.first().source.position - 1, column = 0) + } else { + getOffsetFromStringNumber(sortedDeltas.first().source.position, column = 0) + } + } else { + editor.selectionModel.selectionStart + } + editor.caretModel.moveToOffset(offset) + for (det in sortedDeltas) { + if (det.target.lines == null) continue + when (det.type) { + DeltaType.INSERT -> { + renderInsertBlock( + det.target.lines!!, emptyList(), + getOffsetFromStringNumber(det.source.position + det.source.size() - 1) + ) + } + + DeltaType.CHANGE -> { + rangeHighlighters.add( + editor.markupModel.addRangeHighlighter( + getOffsetFromStringNumber(det.source.position), + getOffsetFromStringNumber(det.source.position + det.source.size()), + 99999, + TextAttributes().apply { + backgroundColor = redColor + }, + HighlighterTargetArea.EXACT_RANGE + ) + ) + val smallPatches: MutableList> = emptyList>().toMutableList() + for (i in 0 until minOf(det.target.size(), det.source.size())) { + val srcLine = det.source.lines?.get(i) + val tgtLine = det.target.lines!![i] + if (srcLine == null) break + + val smallPatch = DiffUtils.diff(srcLine.toList(), tgtLine.toList()) + smallPatches.add(smallPatch) + for (smallDelta in smallPatch.getDeltas()) { + rangeHighlighters.add( + editor.markupModel.addRangeHighlighter( + getOffsetFromStringNumber(det.source.position + i, smallDelta.source.position), + getOffsetFromStringNumber( + det.source.position + i, + smallDelta.source.position + smallDelta.source.size() + ), + 99999, + TextAttributes().apply { + backgroundColor = veryRedColor + }, + HighlighterTargetArea.EXACT_RANGE + ) + ) + } + } + + renderInsertBlock( + det.target.lines!!, + smallPatches, + getOffsetFromStringNumber(det.source.position + det.source.size() - 1) + ) + } + + DeltaType.DELETE -> { + rangeHighlighters.add( + editor.markupModel.addRangeHighlighter( + getOffsetFromStringNumber(det.source.position), + getOffsetFromStringNumber(det.source.position + det.source.size()), + 99999, + TextAttributes().apply { + backgroundColor = redColor + }, + HighlighterTargetArea.EXACT_RANGE + ) + ) + } + + else -> {} + } + } + renderPanel(intent, offset) + return this + } + + private fun getAcceptSymbol(): String { + return when(System.getProperty("os.name")) { + "Mac OS X" -> "\uD83D\uDC4D" + else -> "✓" + } + } + + private fun getRejectSymbol(): String { + return when(System.getProperty("os.name")) { + "Mac OS X" -> "\uD83D\uDC4E" + else -> "×" + } + } + + private fun getRerunSymbol(): String { + return when(System.getProperty("os.name")) { + "Mac OS X" -> "\uD83D\uDD03" + else -> "↻" + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/PanelRenderer.kt b/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/PanelRenderer.kt new file mode 100644 index 00000000..6cca0d01 --- /dev/null +++ b/src/main/kotlin/com/smallcloud/refactai/modes/diff/renderer/PanelRenderer.kt @@ -0,0 +1,112 @@ +package com.smallcloud.refactai.modes.diff.renderer + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorCustomElementRenderer +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.event.EditorMouseEvent +import com.intellij.openapi.editor.event.EditorMouseListener +import com.intellij.openapi.editor.event.EditorMouseMotionListener +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.util.ui.UIUtil +import java.awt.Cursor +import java.awt.Graphics +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.MouseEvent + + +enum class Style { + Normal, Underlined +} + +class PanelRenderer( + private val firstSymbolPos: Point, + private val editor: Editor, + private val labels: List Unit>> +) : EditorCustomElementRenderer, EditorMouseListener, EditorMouseMotionListener, Disposable { + private var inlayVisitor: Inlay<*>? = null + private var xBounds: MutableList> = mutableListOf() + private val styles: MutableList