diff --git a/src/main/java/ee/carlrobert/codegpt/conversations/ConversationService.java b/src/main/java/ee/carlrobert/codegpt/conversations/ConversationService.java index 8508cea1..874eb17d 100644 --- a/src/main/java/ee/carlrobert/codegpt/conversations/ConversationService.java +++ b/src/main/java/ee/carlrobert/codegpt/conversations/ConversationService.java @@ -66,6 +66,7 @@ public final class ConversationService { public void saveMessage(@NotNull Conversation conversation, @NotNull Message message) { conversation.setUpdatedOn(LocalDateTime.now()); conversation.addMessage(message); + saveConversation(conversation); } public void saveConversation(Conversation conversation) { diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/conversations/ConversationPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/conversations/ConversationPanel.java deleted file mode 100644 index 1d11bbe5..00000000 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/conversations/ConversationPanel.java +++ /dev/null @@ -1,114 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.conversations; - -import com.intellij.openapi.project.Project; -import com.intellij.ui.JBColor; -import com.intellij.ui.components.JBLabel; -import com.intellij.util.ui.JBFont; -import com.intellij.util.ui.JBUI; -import ee.carlrobert.codegpt.actions.toolwindow.DeleteConversationAction; -import ee.carlrobert.codegpt.conversations.Conversation; -import ee.carlrobert.codegpt.conversations.ConversationsState; -import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager; -import ee.carlrobert.codegpt.ui.IconActionButton; -import java.awt.BorderLayout; -import java.awt.Cursor; -import java.awt.GridBagConstraints; -import java.awt.GridBagLayout; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.time.format.DateTimeFormatter; -import javax.swing.JLabel; -import javax.swing.JPanel; -import org.jetbrains.annotations.NotNull; - -class ConversationPanel extends JPanel { - - ConversationPanel( - @NotNull Project project, - @NotNull Conversation conversation, - @NotNull Runnable onDelete) { - super(new BorderLayout()); - var toolWindowContentManager = project.getService(ChatToolWindowContentManager.class); - init(toolWindowContentManager, conversation, onDelete); - } - - private void init( - ChatToolWindowContentManager toolWindowContentManager, - Conversation conversation, - Runnable onDelete) { - setBackground(JBColor.background()); - addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - toolWindowContentManager.displayConversation(conversation); - } - }); - addStyles(isSelected(conversation)); - addTextPanel(conversation, onDelete); - setCursor(new Cursor(Cursor.HAND_CURSOR)); - } - - private boolean isSelected(Conversation conversation) { - var currentConversation = ConversationsState.getCurrentConversation(); - return currentConversation != null && currentConversation.getId().equals(conversation.getId()); - } - - private void addStyles(boolean isSelected) { - var border = isSelected - ? JBUI.Borders.customLine(JBUI.CurrentTheme.ActionButton.focusedBorder(), 2, 2, 2, 2) - : JBUI.Borders.customLine(JBColor.border(), 1, 0, 1, 0); - setBorder(JBUI.Borders.compound(border, JBUI.Borders.empty(8))); - setLayout(new GridBagLayout()); - setCursor(new Cursor(Cursor.HAND_CURSOR)); - } - - private void addTextPanel(Conversation conversation, Runnable onDelete) { - var constraints = new GridBagConstraints(); - constraints.gridx = 1; - constraints.weightx = 1.0; - constraints.fill = GridBagConstraints.HORIZONTAL; - add(createTextPanel(conversation, onDelete), constraints); - } - - private JPanel createTextPanel(Conversation conversation, Runnable onDelete) { - var headerPanel = new JPanel(new GridBagLayout()); - headerPanel.setBorder(JBUI.Borders.emptyBottom(12)); - - var gbc = new GridBagConstraints(); - gbc.fill = GridBagConstraints.HORIZONTAL; - gbc.weightx = 1.0; - gbc.gridx = 0; - - headerPanel.add(new JBLabel(getConversationDisplayTitle(conversation)) - .withFont(JBFont.label().asBold()), gbc); - - gbc.gridx = 1; - gbc.weightx = 0; - headerPanel.add(new IconActionButton(new DeleteConversationAction(onDelete), "DELETE"), gbc); - - var bottomPanel = new JPanel(new BorderLayout()); - bottomPanel.add(new JLabel(conversation.getUpdatedOn() - .format(DateTimeFormatter.ofPattern("M/d/yyyy, h:mm:ss a"))), BorderLayout.WEST); - - var textPanel = new JPanel(new BorderLayout()); - textPanel.add(headerPanel, BorderLayout.NORTH); - textPanel.add(bottomPanel, BorderLayout.SOUTH); - return textPanel; - } - - private String getConversationDisplayTitle(Conversation conversation) { - String title = conversation.getTitle(); - if (title != null && !title.trim().isEmpty()) { - return title; - } - return getFirstPrompt(conversation); - } - - private String getFirstPrompt(Conversation conversation) { - var messages = conversation.getMessages(); - if (messages.isEmpty()) { - return ""; - } - return messages.get(0).getPrompt(); - } -} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/conversations/ConversationsToolWindow.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/conversations/ConversationsToolWindow.java deleted file mode 100644 index 8ec5dcd7..00000000 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/conversations/ConversationsToolWindow.java +++ /dev/null @@ -1,83 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.conversations; - -import com.intellij.openapi.actionSystem.ActionManager; -import com.intellij.openapi.actionSystem.DefaultActionGroup; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.roots.ui.componentsList.components.ScrollablePanel; -import com.intellij.openapi.ui.SimpleToolWindowPanel; -import com.intellij.ui.components.JBScrollPane; -import com.intellij.util.ui.JBFont; -import com.intellij.util.ui.JBUI; -import ee.carlrobert.codegpt.actions.toolwindow.DeleteAllConversationsAction; -import ee.carlrobert.codegpt.actions.toolwindow.MoveDownAction; -import ee.carlrobert.codegpt.actions.toolwindow.MoveUpAction; -import ee.carlrobert.codegpt.conversations.ConversationService; -import javax.swing.Box; -import javax.swing.BoxLayout; -import javax.swing.JLabel; -import javax.swing.JPanel; -import javax.swing.JScrollPane; -import javax.swing.ScrollPaneConstants; -import org.jetbrains.annotations.NotNull; - -public class ConversationsToolWindow extends JPanel { - - private final Project project; - private final ConversationService conversationService; - private final ScrollablePanel scrollablePanel; - private final JScrollPane scrollPane; - - public ConversationsToolWindow(@NotNull Project project) { - this.project = project; - this.conversationService = ConversationService.getInstance(); - scrollablePanel = new ScrollablePanel(); - scrollablePanel.setLayout(new BoxLayout(scrollablePanel, BoxLayout.Y_AXIS)); - - scrollPane = new JBScrollPane(); - scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); - scrollPane.setViewportView(scrollablePanel); - scrollPane.setBorder(null); - scrollPane.setViewportBorder(null); - refresh(); - } - - public JPanel getContent() { - SimpleToolWindowPanel panel = new SimpleToolWindowPanel(true); - panel.setContent(scrollPane); - - var actionGroup = new DefaultActionGroup("TOOLBAR_ACTION_GROUP", false); - actionGroup.add(new MoveDownAction(this::refresh)); - actionGroup.add(new MoveUpAction(this::refresh)); - actionGroup.addSeparator(); - actionGroup.add(new DeleteAllConversationsAction(this::refresh)); - - var toolbar = ActionManager.getInstance() - .createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true); - toolbar.setTargetComponent(panel); - panel.setToolbar(toolbar.getComponent()); - return panel; - } - - public void refresh() { - scrollablePanel.removeAll(); - - var sortedConversations = conversationService.getSortedConversations(); - if (sortedConversations.isEmpty()) { - var emptyLabel = new JLabel("No conversations exist."); - emptyLabel.setFont(JBFont.h2()); - emptyLabel.setBorder(JBUI.Borders.empty(8)); - scrollablePanel.add(emptyLabel); - } else { - sortedConversations.forEach(conversation -> { - scrollablePanel.add(Box.createVerticalStrut(8)); - scrollablePanel.add(new ConversationPanel(project, conversation, () -> { - ConversationService.getInstance().deleteConversation(conversation); - refresh(); - })); - }); - } - - scrollablePanel.revalidate(); - scrollablePanel.repaint(); - } -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ProxyAIToolWindowFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ProxyAIToolWindowFactory.kt index c299f55a..5d7fa91a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ProxyAIToolWindowFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ProxyAIToolWindowFactory.kt @@ -5,9 +5,9 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentManagerEvent -import com.intellij.ui.content.ContentManagerListener; +import com.intellij.ui.content.ContentManagerListener import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowPanel -import ee.carlrobert.codegpt.toolwindow.conversations.ConversationsToolWindow +import ee.carlrobert.codegpt.toolwindow.history.ChatHistoryToolWindow import javax.swing.JComponent class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware { @@ -17,14 +17,15 @@ class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware { toolWindow: ToolWindow ) { var chatToolWindowPanel = ChatToolWindowPanel(project, toolWindow.disposable) - var conversationsToolWindow = ConversationsToolWindow(project) + var chatHistoryToolWindow = ChatHistoryToolWindow(project) addContent(toolWindow, chatToolWindowPanel, "Chat") - addContent(toolWindow, conversationsToolWindow.getContent(), "Chat History") + addContent(toolWindow, chatHistoryToolWindow.getContent(), "Chat History") + toolWindow.addContentManagerListener(object : ContentManagerListener { override fun selectionChanged(event: ContentManagerEvent) { if ("Chat History" == event.content.tabName && event.content.isSelected) { - conversationsToolWindow.refresh() + chatHistoryToolWindow.refresh() } } }) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryItemPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryItemPanel.kt new file mode 100644 index 00000000..510cb549 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryItemPanel.kt @@ -0,0 +1,271 @@ +package ee.carlrobert.codegpt.toolwindow.history + +import com.intellij.icons.AllIcons +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.* +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.conversations.Conversation +import java.awt.Color +import java.awt.Cursor +import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.time.format.DateTimeFormatter +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.SwingUtilities +import kotlin.math.max + +class ChatHistoryItemPanel( + private val conversation: Conversation, + private val onClicked: () -> Unit, + private val onDoubleClicked: () -> Unit, + private val onDeleteClicked: () -> Unit, + private var isSelected: Boolean = false +) : BorderLayoutPanel() { + + companion object { + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, h:mm a") + private const val TITLE_MAX_LENGTH = 60 + private const val PREVIEW_MAX_LENGTH = 100 + private const val MIN_PANEL_HEIGHT = 80 + private const val BUTTON_SIZE = 20 + } + + private val deleteButton: JButton = createDeleteButton() + private var isHovered = false + + init { + setupUI() + setupMouseListeners() + } + + fun setSelected(selected: Boolean) { + if (isSelected != selected) { + isSelected = selected + setupBackground() + repaint() + } + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(size.width, max(size.height, MIN_PANEL_HEIGHT)) + } + + private fun setupUI() { + cursor = Cursor(Cursor.HAND_CURSOR) + border = JBUI.Borders.customLine(JBColor.border(), 0, 0, 1, 0) + setupBackground() + addToCenter(createMainPanel()) + } + + private fun setupMouseListeners() { + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + when (e.clickCount) { + 1 -> onClicked() + 2 -> onDoubleClicked() + } + } + + override fun mouseEntered(e: MouseEvent) { + isHovered = true + setupBackground() + repaint() + deleteButton.icon = AllIcons.Actions.Close + deleteButton.rolloverIcon = AllIcons.Actions.CloseHovered + } + + override fun mouseExited(e: MouseEvent) { + val point = + SwingUtilities.convertPoint(e.component, e.point, this@ChatHistoryItemPanel) + if (!contains(point)) { + isHovered = false + setupBackground() + repaint() + deleteButton.icon = null + deleteButton.rolloverIcon = null + } + } + }) + } + + private fun setupBackground() { + background = when { + isSelected -> UIUtil.getListSelectionBackground(true) + isHovered -> UIUtil.getListBackground().darker() + else -> UIUtil.getListBackground() + } + } + + private fun createDeleteButton(): JButton { + return JButton().apply { + isOpaque = false + isContentAreaFilled = false + isBorderPainted = false + isFocusPainted = false + toolTipText = CodeGPTBundle.get("conversation.deleteButton.tooltip") + preferredSize = Dimension(BUTTON_SIZE, BUTTON_SIZE) + minimumSize = Dimension(BUTTON_SIZE, BUTTON_SIZE) + maximumSize = Dimension(BUTTON_SIZE, BUTTON_SIZE) + icon = null + + addActionListener { + onDeleteClicked() + } + + isVisible = true + + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + isHovered = true + setupBackground() + this@ChatHistoryItemPanel.repaint() + } + + override fun mouseExited(e: MouseEvent) { + val parentPoint = + SwingUtilities.convertPoint(e.component, e.point, this@ChatHistoryItemPanel) + if (!this@ChatHistoryItemPanel.contains(parentPoint)) { + isHovered = false + setupBackground() + this@ChatHistoryItemPanel.repaint() + icon = null + rolloverIcon = null + } + } + }) + } + } + + private fun createMainPanel(): JPanel { + return panel { + row { + cell(createTitlePanel()) + .align(Align.FILL) + }.resizableRow() + + val previewText = getPreviewText() + if (previewText.isNotEmpty()) { + row { + cell(createPreviewLabel(previewText)) + .align(Align.FILL) + }.topGap(TopGap.NONE) + } + + row { + cell(createMetadataPanel()) + .align(Align.FILL) + }.topGap(TopGap.NONE) + }.apply { + isOpaque = false + border = JBUI.Borders.empty(4, 8) + } + } + + private fun createTitlePanel(): JPanel { + return panel { + row { + cell(createTitleLabel()) + .align(AlignX.FILL) + .resizableColumn() + cell(deleteButton) + .align(AlignY.CENTER) + } + }.apply { + isOpaque = false + border = JBUI.Borders.empty(2, 0, 2, 0) + } + } + + private fun createPreviewLabel(previewText: String): JBLabel { + return JBLabel(previewText).apply { + font = JBFont.regular().deriveFont(12f) + foreground = getPreviewColor() + maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height * 2) + } + } + + private fun createMetadataPanel(): JPanel { + return panel { + row { + label(formatDate()) + .applyToComponent { + font = JBFont.regular().deriveFont(11f) + foreground = getMetadataColor() + } + .align(AlignX.LEFT) + .resizableColumn() + + val messageCount = conversation.messages.size + if (messageCount > 0) { + val text = if (messageCount == 1) { + CodeGPTBundle.get("conversation.messageCount.singular", messageCount) + } else { + CodeGPTBundle.get("conversation.messageCount.plural", messageCount) + } + label(text) + .applyToComponent { + font = JBFont.regular().deriveFont(11f) + foreground = getMetadataColor() + } + .align(AlignX.RIGHT) + } + } + }.apply { + isOpaque = false + } + } + + private fun createTitleLabel(): JBLabel { + return JBLabel(getConversationDisplayTitle()).apply { + font = JBFont.label().deriveFont(14f).asBold() + foreground = UIUtil.getLabelForeground() + } + } + + private fun getPreviewColor(): Color { + return JBColor(Color(128, 128, 128), Color(169, 169, 169)) + } + + private fun getMetadataColor(): Color { + return JBColor(Color(150, 150, 150), Color(130, 130, 130)) + } + + private fun getConversationDisplayTitle(): String { + val title = conversation.title?.takeIf { it.isNotBlank() } ?: getFirstPrompt() + return if (title.length > TITLE_MAX_LENGTH) { + title.substring(0, TITLE_MAX_LENGTH - 3) + "..." + } else { + title + } + } + + private fun getFirstPrompt(): String { + return conversation.messages.firstOrNull()?.prompt?.trim() + ?: CodeGPTBundle.get("conversation.defaultTitle") + } + + private fun getPreviewText(): String { + val lastMessage = conversation.messages.lastOrNull() ?: return "" + val text = lastMessage.response?.trim() ?: lastMessage.prompt?.trim() ?: "" + + val cleanedText = text.replace("\n", " ").replace(Regex("\\s+"), " ").trim() + + return if (cleanedText.length > PREVIEW_MAX_LENGTH) { + cleanedText.substring(0, PREVIEW_MAX_LENGTH - 3) + "..." + } else { + cleanedText + } + } + + private fun formatDate(): String { + return conversation.updatedOn.format(DATE_FORMATTER) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryListPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryListPanel.kt new file mode 100644 index 00000000..89817c2b --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryListPanel.kt @@ -0,0 +1,196 @@ +package ee.carlrobert.codegpt.toolwindow.history + +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.conversations.Conversation +import java.awt.BorderLayout +import java.awt.Rectangle +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import java.util.* +import javax.swing.BoxLayout +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +class ChatHistoryListPanel : BorderLayoutPanel() { + + private val itemsPanel = JPanel() + private var scrollPane = JBScrollPane(itemsPanel) + private var conversations = listOf() + private var selectedConversation: Conversation? = null + private var onConversationSelected: ((Conversation?) -> Unit)? = null + private var onConversationDoubleClicked: ((Conversation) -> Unit)? = null + private var onConversationDeleted: ((Conversation) -> Unit)? = null + private val conversationPanels = mutableMapOf() + + init { + setupItemsPanel() + setupScrollPane() + setupKeyboardSupport() + } + + fun setConversations(newConversations: List) { + val previousSelection = selectedConversation + conversations = newConversations + rebuildItems() + + previousSelection?.let { selected -> + conversations.find { it.id == selected.id }?.let { setSelectedConversation(it) } + } + } + + fun setSelectedConversation(conversation: Conversation?) { + if (selectedConversation != conversation) { + selectedConversation?.let { prev -> + conversationPanels[prev.id]?.setSelected(false) + } + + selectedConversation = conversation + + conversation?.let { conv -> + conversationPanels[conv.id]?.setSelected(true) + } + + onConversationSelected?.invoke(conversation) + } + } + + fun setOnConversationSelected(callback: (Conversation?) -> Unit) { + onConversationSelected = callback + } + + fun setOnConversationDoubleClicked(callback: (Conversation) -> Unit) { + onConversationDoubleClicked = callback + } + + fun setOnConversationDeleted(callback: (Conversation) -> Unit) { + onConversationDeleted = callback + } + + private fun setupItemsPanel() { + itemsPanel.layout = BoxLayout(itemsPanel, BoxLayout.Y_AXIS) + itemsPanel.background = UIUtil.getListBackground() + } + + private fun setupScrollPane() { + scrollPane.apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + border = null + viewportBorder = null + background = UIUtil.getListBackground() + } + addToCenter(scrollPane) + } + + private fun setupKeyboardSupport() { + isFocusable = true + addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + when (e.keyCode) { + KeyEvent.VK_UP -> selectPrevious() + KeyEvent.VK_DOWN -> selectNext() + KeyEvent.VK_ENTER -> selectedConversation?.let { + onConversationDoubleClicked?.invoke( + it + ) + } + + KeyEvent.VK_DELETE, KeyEvent.VK_BACK_SPACE -> { + selectedConversation?.let { onConversationDeleted?.invoke(it) } + } + } + } + }) + } + + private fun selectPrevious() { + val currentIndex = selectedConversation?.let { conversations.indexOf(it) } ?: 0 + if (currentIndex > 0) { + setSelectedConversation(conversations[currentIndex - 1]) + scrollToSelected() + } + } + + private fun selectNext() { + val currentIndex = selectedConversation?.let { conversations.indexOf(it) } ?: -1 + if (currentIndex < conversations.size - 1) { + setSelectedConversation(conversations[currentIndex + 1]) + scrollToSelected() + } + } + + private fun scrollToSelected() { + selectedConversation?.let { selected -> + val index = conversations.indexOf(selected) + if (index >= 0 && index < itemsPanel.componentCount) { + val component = itemsPanel.getComponent(index) + scrollPane.viewport.scrollRectToVisible( + Rectangle(0, component.y, component.width, component.height) + ) + } + } + } + + private fun rebuildItems() { + itemsPanel.removeAll() + + if (conversations.isEmpty()) { + addEmptyState() + } else { + addConversationItems() + } + + itemsPanel.revalidate() + itemsPanel.repaint() + } + + private fun addEmptyState() { + val emptyPanel = panel { + row { + label(CodeGPTBundle.get("conversation.emptyState")) + .applyToComponent { + font = JBFont.regular().deriveFont(14f) + foreground = UIUtil.getInactiveTextColor() + } + } + }.apply { + border = JBUI.Borders.empty(40) + isOpaque = false + } + + itemsPanel.layout = BorderLayout() + itemsPanel.add(emptyPanel, BorderLayout.CENTER) + } + + private fun addConversationItems() { + itemsPanel.layout = BoxLayout(itemsPanel, BoxLayout.Y_AXIS) + + conversationPanels.clear() + + conversations.forEach { conversation -> + val isCurrentlySelected = selectedConversation?.id == conversation.id + val itemPanel = ChatHistoryItemPanel( + conversation = conversation, + onClicked = { + setSelectedConversation(conversation) + onConversationDoubleClicked?.invoke(conversation) + }, + onDoubleClicked = { onConversationDoubleClicked?.invoke(conversation) }, + onDeleteClicked = { onConversationDeleted?.invoke(conversation) }, + isSelected = isCurrentlySelected + ) + + itemPanel.alignmentX = LEFT_ALIGNMENT + itemPanel.maximumSize = + java.awt.Dimension(Integer.MAX_VALUE, itemPanel.preferredSize.height) + itemsPanel.add(itemPanel) + conversationPanels[conversation.id] = itemPanel + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryToolWindow.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryToolWindow.kt new file mode 100644 index 00000000..b65a316d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryToolWindow.kt @@ -0,0 +1,413 @@ +package ee.carlrobert.codegpt.toolwindow.history + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Key +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.SearchTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.actions.toolwindow.DeleteAllConversationsAction +import ee.carlrobert.codegpt.conversations.Conversation +import ee.carlrobert.codegpt.conversations.ConversationService +import ee.carlrobert.codegpt.conversations.ConversationsState +import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager +import javax.swing.JOptionPane +import javax.swing.JPanel +import javax.swing.SwingUtilities +import javax.swing.Timer +import javax.swing.event.DocumentEvent +import kotlin.concurrent.thread + +class ChatHistoryToolWindow(private val project: Project) : BorderLayoutPanel() { + + companion object { + private val KEY: Key = Key.create("SELECTED_STATE") + private const val SEARCH_DEBOUNCE_MS = 300 + private const val MAX_MESSAGES_TO_SEARCH = 5 + } + + private val conversationService = ConversationService.getInstance() + private val chatHistoryListPanel = ChatHistoryListPanel() + private val searchField = SearchTextField() + private var allConversations = mutableListOf() + private var sortOption = SortOption.UPDATED_DATE_DESC + private val statusLabel = JBLabel().apply { + font = JBFont.small() + foreground = UIUtil.getContextHelpForeground() + border = JBUI.Borders.empty(2, 8) + } + private var lastSearchText = "" + private var lastFilteredConversations: List? = null + private var isDataLoaded = false + + enum class SortOption(val propertyKey: String) { + UPDATED_DATE_DESC("conversation.sortOption.recentlyUpdated"), + UPDATED_DATE_ASC("conversation.sortOption.oldestFirst"), + TITLE_ASC("conversation.sortOption.titleAscending"), + TITLE_DESC("conversation.sortOption.titleDescending"), + MESSAGE_COUNT_DESC("conversation.sortOption.mostMessages"), + MESSAGE_COUNT_ASC("conversation.sortOption.leastMessages"); + + val displayName: String + get() = CodeGPTBundle.get(propertyKey) + } + + init { + setupUI() + loadConversationsAsync() + setupListeners() + } + + private fun setupUI() { + chatHistoryListPanel.apply { + setOnConversationSelected { conversation -> + ConversationsState.getInstance().setCurrentConversation(conversation) + } + + setOnConversationDoubleClicked { conversation -> + project.getService(ChatToolWindowContentManager::class.java) + .displayConversation(conversation) + } + + setOnConversationDeleted { conversation -> + deleteConversation(conversation) + } + } + + val searchPanel = createSearchPanel() + + val topPanel = panel { + row { + cell(searchPanel) + .align(AlignX.FILL) + }.resizableRow() + row { + cell(statusLabel) + .align(AlignX.FILL) + .applyToComponent { + border = JBUI.Borders.empty(2, 8) + } + } + separator() + }.apply { + background = UIUtil.getPanelBackground() + } + + addToTop(topPanel) + addToCenter(chatHistoryListPanel) + } + + private fun createSearchPanel(): JPanel { + searchField.apply { + textEditor.emptyText.text = CodeGPTBundle.get("conversation.searchField.placeholder") + textEditor.border = JBUI.Borders.empty(2) + textEditor.background = UIUtil.getPanelBackground().darker() + } + + return panel { + row { + cell(searchField) + .align(AlignX.FILL) + .resizableColumn() + } + }.apply { + border = JBUI.Borders.empty(4, 8, 4, 8) + background = UIUtil.getPanelBackground() + } + } + + private fun updateSearchFieldState() { + SwingUtilities.invokeLater { + val hasText = searchField.text.isNotBlank() + if (hasText && allConversations.isNotEmpty()) { + val matchCount = countMatchingConversations(searchField.text) + searchField.textEditor.emptyText.text = formatSearchResultMessage(matchCount) + } else { + searchField.textEditor.emptyText.text = + CodeGPTBundle.get("conversation.searchField.placeholder") + } + } + } + + private fun countMatchingConversations(searchText: String): Int { + return allConversations.count { conversation -> + matchesSearchText(conversation, searchText) + } + } + + private fun formatSearchResultMessage(count: Int): String { + return if (count == 1) { + CodeGPTBundle.get("conversation.searchResult.singular", count) + } else { + CodeGPTBundle.get("conversation.searchResult.plural", count) + } + } + + private fun filterConversations(searchText: String): List { + return if (searchText.isBlank()) { + lastSearchText = "" + lastFilteredConversations = null + allConversations + } else if (searchText == lastSearchText && lastFilteredConversations != null) { + lastFilteredConversations!! + } else { + val startList = getOptimizedSearchStartList(searchText) + val filtered = startList.filter { conversation -> + matchesSearchText(conversation, searchText) + } + cacheFilterResults(searchText, filtered) + filtered + } + } + + private fun getOptimizedSearchStartList(searchText: String): List { + return if (lastSearchText.isNotEmpty() && searchText.startsWith(lastSearchText) && lastFilteredConversations != null) { + lastFilteredConversations!! + } else { + allConversations + } + } + + private fun cacheFilterResults(searchText: String, filtered: List) { + lastSearchText = searchText + lastFilteredConversations = filtered + } + + private fun getSortIcon(sortOption: SortOption) = when (sortOption) { + SortOption.UPDATED_DATE_DESC -> AllIcons.ObjectBrowser.SortByType + SortOption.UPDATED_DATE_ASC -> AllIcons.ObjectBrowser.SortByType + SortOption.TITLE_ASC -> AllIcons.ObjectBrowser.Sorted + SortOption.TITLE_DESC -> AllIcons.ObjectBrowser.Sorted + SortOption.MESSAGE_COUNT_DESC -> AllIcons.Actions.ListFiles + SortOption.MESSAGE_COUNT_ASC -> AllIcons.Actions.ListFiles + } + + private fun createSortAction(): AnAction { + val sortAction = object : AnAction( + CodeGPTBundle.get("conversation.sortAction.title", sortOption.displayName), + CodeGPTBundle.get("conversation.sortAction.description", sortOption.displayName), + AllIcons.ObjectBrowser.Sorted + ) { + override fun actionPerformed(e: AnActionEvent) { + val actionGroup = DefaultActionGroup().apply { + SortOption.entries.forEach { option -> + add(object : AnAction(option.displayName, null, getSortIcon(option)) { + override fun actionPerformed(e: AnActionEvent) { + sortOption = option + sortAndFilterConversations() + } + + override fun update(e: AnActionEvent) { + e.presentation.putClientProperty(KEY, sortOption == option) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + }) + } + } + + val popup = JBPopupFactory.getInstance() + .createActionGroupPopup( + CodeGPTBundle.get("conversation.sortPopup.title"), + actionGroup, + e.dataContext, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true + ) + + val component = e.inputEvent?.component + if (component != null) { + popup.showUnderneathOf(component) + } else { + popup.showInBestPositionFor(e.dataContext) + } + } + + override fun update(e: AnActionEvent) { + e.presentation.text = + CodeGPTBundle.get("conversation.sortAction.title", sortOption.displayName) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + } + return sortAction + } + + private fun setupListeners() { + var searchTimer: Timer? = null + searchField.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + updateSearchFieldState() + searchTimer?.stop() + searchTimer = Timer(SEARCH_DEBOUNCE_MS) { + SwingUtilities.invokeLater { + sortAndFilterConversations() + } + } + searchTimer.isRepeats = false + searchTimer.start() + } + }) + } + + fun getContent(): JPanel { + val panel = SimpleToolWindowPanel(true) + panel.setContent(this) + val actionGroup = DefaultActionGroup("TOOLBAR_ACTION_GROUP", false).apply { + add(object : AnAction( + CodeGPTBundle.get("conversation.refreshAction.title"), + CodeGPTBundle.get("conversation.refreshAction.description"), + AllIcons.Actions.Refresh + ) { + override fun actionPerformed(e: AnActionEvent) { + refresh() + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + }) + add(createSortAction()) + addSeparator() + add(DeleteAllConversationsAction { refresh() }) + } + + val toolbar = ActionManager.getInstance() + .createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true) + toolbar.targetComponent = panel + panel.toolbar = toolbar.component + + return panel + } + + fun refresh() { + loadConversationsAsync() + } + + private fun loadConversationsAsync() { + thread { + val conversations = conversationService.sortedConversations + .filter { it.messages.isNotEmpty() } + .filter { !(it.messages.size == 1 && it.messages[0].response.isNullOrBlank()) } + .toMutableList() + SwingUtilities.invokeLater { + allConversations = conversations + lastSearchText = "" + lastFilteredConversations = null + isDataLoaded = true + sortAndFilterConversations() + } + } + } + + private fun sortAndFilterConversations() { + val searchText = searchField.text + val filteredConversations = filterConversations(searchText) + val sortedConversations = applySorting(filteredConversations) + updateList(sortedConversations) + } + + private fun applySorting(conversations: List): List { + return when (sortOption) { + SortOption.UPDATED_DATE_DESC -> conversations.sortedByDescending { it.updatedOn } + SortOption.UPDATED_DATE_ASC -> conversations.sortedBy { it.updatedOn } + SortOption.TITLE_ASC -> conversations.sortedBy { getConversationDisplayTitle(it).lowercase() } + SortOption.TITLE_DESC -> conversations.sortedByDescending { + getConversationDisplayTitle( + it + ).lowercase() + } + + SortOption.MESSAGE_COUNT_DESC -> conversations.sortedByDescending { it.messages.size } + SortOption.MESSAGE_COUNT_ASC -> conversations.sortedBy { it.messages.size } + } + } + + private fun getConversationDisplayTitle(conversation: Conversation): String { + return conversation.title?.takeIf { it.isNotBlank() } + ?: conversation.messages.firstOrNull()?.prompt?.take(50) + ?: CodeGPTBundle.get("conversation.defaultTitle") + } + + private fun matchesSearchText(conversation: Conversation, searchText: String): Boolean { + val searchLower = searchText.lowercase() + return conversation.title?.lowercase()?.contains(searchLower) == true || + conversation.messages.take(MAX_MESSAGES_TO_SEARCH).any { message -> + message.prompt?.lowercase()?.contains(searchLower) == true || + message.response?.lowercase()?.contains(searchLower) == true + } + } + + private fun updateList(conversations: List) { + SwingUtilities.invokeLater { + chatHistoryListPanel.setConversations(conversations) + statusLabel.text = createStatusMessage(conversations) + updateSelectedConversation(conversations) + } + } + + private fun createStatusMessage(conversations: List): String { + val searchText = searchField.text + return buildString { + append(getConversationCountMessage(conversations, searchText)) + append(" • ") + append(CodeGPTBundle.get("conversation.status.sortedBy", sortOption.displayName)) + } + } + + private fun getConversationCountMessage( + conversations: List, + searchText: String + ): String { + return if (searchText.isNotBlank()) { + CodeGPTBundle.get( + "conversation.status.searchResult", + conversations.size, + allConversations.size + ) + } else { + if (conversations.size == 1) { + CodeGPTBundle.get("conversation.status.count.singular", conversations.size) + } else { + CodeGPTBundle.get("conversation.status.count.plural", conversations.size) + } + } + } + + private fun updateSelectedConversation(conversations: List) { + ConversationsState.getCurrentConversation()?.let { current -> + conversations.find { it.id == current.id }?.let { conversation -> + chatHistoryListPanel.setSelectedConversation(conversation) + } + } + } + + private fun deleteConversation(conversation: Conversation) { + val result = JOptionPane.showConfirmDialog( + this, + CodeGPTBundle.get("conversation.deleteConfirmation.message"), + CodeGPTBundle.get("conversation.deleteConfirmation.title"), + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE + ) + + if (result == JOptionPane.YES_OPTION) { + conversationService.deleteConversation(conversation) + refresh() + } + } +} \ No newline at end of file diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index ee4b1601..2cdf6fc9 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -370,3 +370,28 @@ settings.models.chat.section.title=Chat settings.models.chat.section.description=Models for conversations, code edits, auto applies, commits, and naming suggestions. Learn more settings.models.tab.section.title=Tab settings.models.tab.section.description=Models for autocomplete and multi-line next edit suggestions. Learn more +conversation.deleteButton.tooltip=Delete conversation +conversation.messageCount.singular={0} message +conversation.messageCount.plural={0} messages +conversation.defaultTitle=New Conversation +conversation.emptyState=Your conversations will be saved here for easy access. +conversation.searchField.placeholder=Search in titles and messages... +conversation.searchResult.singular={0} match found +conversation.searchResult.plural={0} matches found +conversation.sortOption.recentlyUpdated=Recently Updated +conversation.sortOption.oldestFirst=Oldest First +conversation.sortOption.titleAscending=Title (A-Z) +conversation.sortOption.titleDescending=Title (Z-A) +conversation.sortOption.mostMessages=Most Messages +conversation.sortOption.leastMessages=Least Messages +conversation.sortAction.title=Sort: {0} +conversation.sortAction.description=Sort conversations by {0} +conversation.sortPopup.title=Sort By +conversation.refreshAction.title=Refresh +conversation.refreshAction.description=Refresh conversation list +conversation.status.searchResult=Found {0} of {1} conversations +conversation.status.count.singular={0} conversation +conversation.status.count.plural={0} conversations +conversation.status.sortedBy=Sorted by: {0} +conversation.deleteConfirmation.message=Are you sure you want to delete this conversation? +conversation.deleteConfirmation.title=Delete Conversation