diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeHistoryStates.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeHistoryStates.kt new file mode 100644 index 00000000..be31a59d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeHistoryStates.kt @@ -0,0 +1,99 @@ +package ee.carlrobert.codegpt.actions.editor + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.* +import com.intellij.util.messages.Topic +import com.intellij.util.xmlb.XmlSerializerUtil +import com.intellij.util.xmlb.annotations.CollectionBean +import com.intellij.util.xmlb.annotations.Transient +import ee.carlrobert.codegpt.util.trimToSize +import kotlin.properties.Delegates + +/** + * Persistent plugin states. + */ +@State(name = "CodeGPT_EditCodeHistory", storages = [(Storage("CodeGPT_EditCodeHistory.xml"))]) +@Service +class EditCodeHistoryStates : PersistentStateComponent { + + @CollectionBean + private val histories: MutableList = ArrayList(DEFAULT_HISTORY_SIZE) + + var maxHistorySize by Delegates.vetoable(DEFAULT_HISTORY_SIZE) { _, oldValue: Int, newValue: Int -> + if (oldValue == newValue || newValue < 0) { + return@vetoable false + } + + trimHistoriesSize(newValue) + true + } + + @Transient + private val dataChangePublisher: HistoriesChangedListener = + ApplicationManager.getApplication().messageBus.syncPublisher(HistoriesChangedListener.TOPIC) + + override fun getState(): EditCodeHistoryStates = this + + override fun loadState(state: EditCodeHistoryStates) { + XmlSerializerUtil.copyBean(state, this) + } + + private fun trimHistoriesSize(maxSize: Int) { + if (histories.trimToSize(maxSize)) { + dataChangePublisher.onHistoriesChanged() + } + } + + fun getHistories(): List = histories + + fun addHistory(query: String) { + val maxSize = maxHistorySize + if (maxSize <= 0) { + return + } + + histories.run { + val index = indexOf(query) + if (index != 0) { + if (index > 0) { + removeAt(index) + } + + add(0, query) + trimToSize(maxSize) + dataChangePublisher.onHistoryItemChanged(query) + } + } + } + + fun clearHistories() { + if (histories.isNotEmpty()) { + histories.clear() + dataChangePublisher.onHistoriesChanged() + } + } + + companion object { + private const val DEFAULT_HISTORY_SIZE = 50 + + /** + * Get the instance of [EditCodeHistoryStates]. + */ + fun getInstance(): EditCodeHistoryStates { + return service().state + } + } +} + +interface HistoriesChangedListener { + + fun onHistoriesChanged() + + fun onHistoryItemChanged(newHistory: String) + + companion object { + @Topic.AppLevel + val TOPIC: Topic = + Topic.create("TranslateHistoriesChanged", HistoriesChangedListener::class.java) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/HistoryModel.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/HistoryModel.kt new file mode 100644 index 00000000..4d0269b0 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/HistoryModel.kt @@ -0,0 +1,48 @@ +package ee.carlrobert.codegpt.actions.editor + +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.SimpleListCellRenderer +import javax.swing.JList + + +class HistoryRenderer : SimpleListCellRenderer() { + + private val builder = StringBuilder() + + override fun customize( + list: JList, + value: String?, + index: Int, + selected: Boolean, + hasFocus: Boolean + ) { + if (list.width == 0 || value.isNullOrBlank()) { + text = null + } else { + setRenderText(value) + } + } + + + private fun setRenderText(value: String) { + val text = with(builder) { + setLength(0) + + + append("") + append(trim(value)) + append("") + + builder.append("") + toString() + } + setText(text) + } + + private fun trim(value: String?): String? { + value ?: return null + + val withoutNewLines = StringUtil.convertLineSeparators(value, "") + return StringUtil.first(withoutNewLines, 100, /*appendEllipsis*/ true) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/EditCodePopover.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/EditCodePopover.kt index bf0db230..949e58c6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/EditCodePopover.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/EditCodePopover.kt @@ -1,5 +1,6 @@ package ee.carlrobert.codegpt.ui +import com.intellij.icons.AllIcons import com.intellij.ide.IdeBundle import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.actionSystem.ActionPlaces @@ -11,29 +12,43 @@ import com.intellij.openapi.observable.properties.AtomicBooleanProperty import com.intellij.openapi.observable.properties.ObservableProperty import com.intellij.openapi.observable.util.not import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent import com.intellij.openapi.ui.popup.util.MinimizeButton +import com.intellij.openapi.util.IconLoader import com.intellij.ui.DocumentAdapter +import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBTextField +import com.intellij.ui.components.labels.LinkLabel +import com.intellij.ui.components.panels.NonOpaquePanel import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.Cell import com.intellij.ui.dsl.builder.Row import com.intellij.ui.dsl.builder.panel import com.intellij.ui.layout.ComponentPredicate +import com.intellij.ui.scale.JBUIScale +import com.intellij.util.IconUtil import com.intellij.util.ui.AsyncProcessIcon import com.intellij.util.ui.JBUI import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.actions.editor.EditCodeHistoryStates import ee.carlrobert.codegpt.actions.editor.EditCodeSubmissionHandler -import ee.carlrobert.codegpt.settings.models.ModelSettings -import ee.carlrobert.codegpt.settings.models.SettingsModelComboBoxAction -import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.actions.editor.HistoryRenderer +import ee.carlrobert.codegpt.settings.GeneralSettings +import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.ModelComboBoxAction +import ee.carlrobert.codegpt.util.ApplicationUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import net.miginfocom.layout.CC +import net.miginfocom.layout.LC +import net.miginfocom.swing.MigLayout +import java.awt.Dimension +import java.awt.Point import java.awt.event.KeyAdapter import java.awt.event.KeyEvent -import javax.swing.JButton -import javax.swing.JPanel +import javax.swing.* import javax.swing.event.DocumentEvent data class ObservableProperties( @@ -47,6 +62,7 @@ class EditCodePopover(private val editor: Editor) { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val observableProperties = ObservableProperties() private val submissionHandler = EditCodeSubmissionHandler(editor, observableProperties) + private val promptTextField = JBTextField("", 40).apply { emptyText.appendText(CodeGPTBundle.get("editCodePopover.textField.emptyText")) addKeyListener(object : KeyAdapter() { @@ -58,7 +74,37 @@ class EditCodePopover(private val editor: Editor) { } }) } - private val popup = JBPopupFactory.getInstance() + + /** + * Code editing history storage + */ + private val states = EditCodeHistoryStates.getInstance(); + + /** + * History button, delete button + */ + private val historyButton: LinkLabel = LinkLabel().apply { + icon = AllIcons.Vcs.History + disabledIcon = IconLoader.getDisabledIcon(AllIcons.Vcs.History) + setHoveringIcon(IconUtil.darker(AllIcons.Vcs.History, 3)) + toolTipText = "History" + setListener({ _, _ -> showHistoryPopup() }, null) + } + private val clearButton: LinkLabel = LinkLabel().apply { + icon = AllIcons.Actions.GC + disabledIcon = IconLoader.getDisabledIcon(AllIcons.Actions.GC) + setHoveringIcon(IconUtil.darker(AllIcons.Actions.GC, 3)) + toolTipText = "Clear" + setListener({ _, _ -> + run { + promptTextField.text = null + } + }, null) + } + + private var historyShowing: Boolean = false + + val popup = JBPopupFactory.getInstance() .createComponentPopupBuilder( createPopupPanel(), promptTextField @@ -85,6 +131,11 @@ class EditCodePopover(private val editor: Editor) { row { cell(promptTextField) } + row { + cell(createToolbar(clearButton, historyButton)) + .align(AlignX.FILL) + } + row { comment(CodeGPTBundle.get("editCodePopover.textField.comment")) } @@ -118,11 +169,12 @@ class EditCodePopover(private val editor: Editor) { font = JBUI.Fonts.smallFont() } cell( - SettingsModelComboBoxAction( - FeatureType.EDIT_CODE, - ModelSettings.getInstance().getModelSelection(FeatureType.EDIT_CODE), - {} - ).createCustomComponent(ActionPlaces.UNKNOWN) + ModelComboBoxAction( + ApplicationUtil.findCurrentProject(), + {}, + GeneralSettings.getSelectedService() + ) + .createCustomComponent(ActionPlaces.UNKNOWN) ).align(AlignX.RIGHT) } }.apply { @@ -130,7 +182,46 @@ class EditCodePopover(private val editor: Editor) { } } - private fun Row.button(title: String, visibleIf: ObservableProperty): Cell { + private fun createToolbar(vararg buttons: JComponent): JPanel { + return NonOpaquePanel(migLayout("4")).apply { + add(JPanel().apply { isOpaque = false }, CC().growX().pushX()) // Left glue + buttons.iterator().forEach { + add(it, CC().gapLeft("${JBUIScale.scale(4)}px")) + } + border = JBUI.Borders.empty(2, 0, 4, 0) + } + } + + private fun showHistoryPopup() { + return JBPopupFactory.getInstance().createPopupChooserBuilder(states.getHistories()) + .setVisibleRowCount(7) + .setSelectionMode(ListSelectionModel.SINGLE_SELECTION) + .setItemSelectedCallback { promptTextField.text = it } + .setRenderer(HistoryRenderer()) + .addListener(object : JBPopupListener { + override fun beforeShown(event: LightweightWindowEvent) { + historyShowing = true + val popup = event.asPopup() + popup.size = Dimension(300, popup.size.height) + val relativePoint = RelativePoint(historyButton, Point(0, -JBUI.scale(3))) + val screenPoint = Point(relativePoint.screenPoint).apply { translate(0, -popup.size.height) } + + popup.setLocation(screenPoint) + } + + override fun onClosed(event: LightweightWindowEvent) { + historyShowing = false + } + }) + .createPopup() + .show(historyButton) + } + + fun migLayout(gapX: String = "0!", gapY: String = "0!", insets: String = "0", lcBuilder: (LC.() -> Unit)? = null) = + MigLayout(LC().fill().gridGap(gapX, gapY).insets(insets).also { lcBuilder?.invoke(it) }) + + + fun Row.button(title: String, visibleIf: ObservableProperty): Cell { val button = JButton(title).apply { putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) addActionListener { @@ -150,8 +241,12 @@ class EditCodePopover(private val editor: Editor) { } private fun handleSubmit() { + val text = promptTextField.text + if (text.isNotBlank()) { + states.addHistory(text) + } serviceScope.launch { - submissionHandler.handleSubmit(promptTextField.text) + submissionHandler.handleSubmit(text) promptTextField.text = "" promptTextField.emptyText.text = CodeGPTBundle.get("editCodePopover.textField.followUp.emptyText") @@ -193,4 +288,4 @@ class EditCodePopover(private val editor: Editor) { } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/ListConverter.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/ListConverter.kt new file mode 100644 index 00000000..7fe0d05b --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/ListConverter.kt @@ -0,0 +1,19 @@ +package ee.carlrobert.codegpt.util + +import com.fasterxml.jackson.core.type.TypeReference + +class ListConverter : BaseConverter>(object : TypeReference>() {}) + +/** + * Trims the [MutableList] to [maxSize] + */ +fun MutableList.trimToSize(maxSize: Int): Boolean { + var size = this.size + val trim = size > 0 && size > maxSize + when { + trim && maxSize <= 0 -> clear() + trim -> while (size > maxSize) removeAt(--size) + } + + return trim +}