From 2df9274fffd2dba4fdc67c2c49333d09584bf5ea Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Wed, 11 Sep 2024 12:31:38 +0300 Subject: [PATCH] feat: support quick way of including git commit diffs in the prompt (closes #688) --- .../java/ee/carlrobert/codegpt/Icons.java | 2 + .../chat/ChatToolWindowTabPanel.java | 54 +++++++++++-- .../textarea/PromptTextFieldInlayRenderer.kt | 5 ++ .../suggestion/SuggestionsPopupManager.kt | 1 + .../suggestion/item/SuggestionActionItems.kt | 34 +++++++- .../suggestion/item/SuggestionGroupItems.kt | 28 +++++-- .../renderer/SuggestionItemRenderer.kt | 17 ++++ .../ee/carlrobert/codegpt/util/GitUtil.kt | 80 +++++++++++++++++-- src/main/resources/icons/vcs.svg | 7 ++ src/main/resources/icons/vcs_dark.svg | 7 ++ .../resources/messages/codegpt.properties | 1 + 11 files changed, 215 insertions(+), 21 deletions(-) create mode 100644 src/main/resources/icons/vcs.svg create mode 100644 src/main/resources/icons/vcs_dark.svg diff --git a/src/main/java/ee/carlrobert/codegpt/Icons.java b/src/main/java/ee/carlrobert/codegpt/Icons.java index 660d834d..c9db3e75 100644 --- a/src/main/java/ee/carlrobert/codegpt/Icons.java +++ b/src/main/java/ee/carlrobert/codegpt/Icons.java @@ -33,4 +33,6 @@ public final class Icons { IconLoader.getIcon("/icons/openNewTab.svg", Icons.class); public static final Icon AddFile = IconLoader.getIcon("/icons/addFile.svg", Icons.class); + public static final Icon VCS = + IconLoader.getIcon("/icons/vcs.svg", Icons.class); } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java index 877565ba..23bc16ce 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -39,6 +39,7 @@ import ee.carlrobert.codegpt.ui.textarea.AppliedSuggestionActionInlay; import ee.carlrobert.codegpt.ui.textarea.UserInputPanel; import ee.carlrobert.codegpt.ui.textarea.suggestion.item.CreateDocumentationActionItem; import ee.carlrobert.codegpt.ui.textarea.suggestion.item.DocumentationActionItem; +import ee.carlrobert.codegpt.ui.textarea.suggestion.item.GitCommitActionItem; import ee.carlrobert.codegpt.ui.textarea.suggestion.item.PersonaActionItem; import ee.carlrobert.codegpt.ui.textarea.suggestion.item.WebSearchActionItem; import ee.carlrobert.codegpt.util.EditorUtil; @@ -49,6 +50,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.UUID; +import java.util.function.Function; import javax.swing.JComponent; import javax.swing.JPanel; import kotlin.Unit; @@ -302,7 +304,8 @@ public class ChatToolWindowTabPanel implements Disposable { if (action instanceof AppliedSuggestionActionInlay) { processSuggestionActions( message, - filterActions(appliedInlayActions, AppliedSuggestionActionInlay.class)); + filterActions(appliedInlayActions, AppliedSuggestionActionInlay.class), + text); } else if (action instanceof AppliedCodeActionInlay) { processCodeActions( message, @@ -328,10 +331,12 @@ public class ChatToolWindowTabPanel implements Disposable { private void processSuggestionActions( Message message, - List actions) { + List actions, + String text) { message.setWebSearchIncluded(containsWebSearchActionInlay(actions)); processDocumentationAction(message, actions); processPersonaAction(message, actions); + processGitCommitAction(message, actions, text); } private void processDocumentationAction( @@ -361,20 +366,24 @@ public class ChatToolWindowTabPanel implements Disposable { } } - private void processCodeActions(Message message, List actions, - String text, Editor editor) { + private void processActions( + Message message, + List actions, + String text, + Function codeExtractor, + Function languageExtractor) { var stringBuilder = new StringBuilder(text); var resultStringBuilder = new StringBuilder(); int lastProcessedIndex = 0; for (var actionInlay : actions) { var inlayOffset = actionInlay.getInlay().getOffset(); - var fileExtension = FileUtil.getFileExtension(((EditorEx) editor).getVirtualFile().getName()); resultStringBuilder .append(stringBuilder, lastProcessedIndex, Math.min(stringBuilder.length(), inlayOffset)) .append('\n') - .append(formatCodeBlock(fileExtension, actionInlay.getCode())) + .append(formatCodeBlock(languageExtractor.apply(actionInlay), + codeExtractor.apply(actionInlay))) .append('\n'); lastProcessedIndex = inlayOffset; @@ -387,6 +396,39 @@ public class ChatToolWindowTabPanel implements Disposable { message.setPrompt(result); } + private void processGitCommitAction( + Message message, + List actions, + String text) { + var gitCommitInlays = actions.stream() + .filter(it -> it.getSuggestion() instanceof GitCommitActionItem) + .toList(); + + if (!gitCommitInlays.isEmpty()) { + processActions( + message, + gitCommitInlays, + text, + action -> ((GitCommitActionItem) action.getSuggestion()).getDiffString(), + action -> "shell" + ); + } + } + + private void processCodeActions( + Message message, + List actions, + String text, + Editor editor) { + processActions( + message, + actions, + text, + AppliedCodeActionInlay::getCode, + action -> FileUtil.getFileExtension(((EditorEx) editor).getVirtualFile().getName()) + ); + } + private String formatCodeBlock(String fileExtension, String code) { return String.format("```%s\n%s\n```", fileExtension, code); } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldInlayRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldInlayRenderer.kt index 5b0fc13d..89ae217c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldInlayRenderer.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldInlayRenderer.kt @@ -45,6 +45,11 @@ class PromptTextFieldInlayRenderer( val font = editor.colorsScheme.getFont(EditorFontType.PLAIN) val textWidth = editor.component.getFontMetrics(font) .stringWidth(actionPrefix + (if (text != null) ":$text" else "")) + + if (tooltipText.isNullOrEmpty()) { + return textWidth + closeIcon.iconWidth + JBUI.scale(10) + } + return textWidth + closeIcon.iconWidth + JBUI.scale(10) + helpIcon.iconWidth + JBUI.scale(10) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/SuggestionsPopupManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/SuggestionsPopupManager.kt index c22bad6e..48cc45ac 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/SuggestionsPopupManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/SuggestionsPopupManager.kt @@ -36,6 +36,7 @@ class SuggestionsPopupManager( private val defaultActions: MutableList = mutableListOf( FileSuggestionGroupItem(project), FolderSuggestionGroupItem(project), + GitSuggestionGroupItem(project), PersonaSuggestionGroupItem(), DocumentationSuggestionGroupItem(), WebSearchActionItem(), diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionActionItems.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionActionItems.kt index 43c450a8..3fba7b61 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionActionItems.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionActionItems.kt @@ -1,6 +1,7 @@ package ee.carlrobert.codegpt.ui.textarea.suggestion.item import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ReadAction import com.intellij.openapi.application.readAction import com.intellij.openapi.components.service import com.intellij.openapi.options.ShowSettingsUtil @@ -8,9 +9,10 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vcs.changes.VcsIgnoreManager import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.concurrency.AppExecutorUtil import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.CodeGPTKeys -import ee.carlrobert.codegpt.Icons +import ee.carlrobert.codegpt.EncodingManager import ee.carlrobert.codegpt.settings.GeneralSettings import ee.carlrobert.codegpt.settings.documentation.DocumentationSettings import ee.carlrobert.codegpt.settings.documentation.DocumentationsConfigurable @@ -21,6 +23,8 @@ import ee.carlrobert.codegpt.ui.AddDocumentationDialog import ee.carlrobert.codegpt.ui.DocumentationDetails import ee.carlrobert.codegpt.ui.textarea.FileSearchService import ee.carlrobert.codegpt.ui.textarea.PromptTextField +import ee.carlrobert.codegpt.util.GitUtil +import git4idea.GitCommit class FileActionItem(val file: VirtualFile) : SuggestionActionItem { override val displayName = file.name @@ -98,6 +102,34 @@ class CreateDocumentationActionItem : SuggestionActionItem { } } +class GitCommitActionItem( + private val project: Project, + val gitCommit: GitCommit, +) : SuggestionActionItem { + + companion object { + private const val MAX_TOKENS = 4096 + } + + val description: String = gitCommit.id.asString().take(6) + + override val displayName: String = gitCommit.subject + override val icon = AllIcons.Vcs.CommitNode + + override suspend fun execute(project: Project, textPane: PromptTextField) { + textPane.addInlayElement("commit", gitCommit.id.asString().take(6), this) + } + + fun getDiffString(): String { + return ReadAction.nonBlocking { + val repository = GitUtil.getProjectRepository(project) ?: return@nonBlocking "" + val diff = GitUtil.getCommitDiff(project, repository, gitCommit.id.asString()) + .joinToString("\n") + service().truncateText(diff, MAX_TOKENS, true) + }.submit(AppExecutorUtil.getAppExecutorService()).get() + } +} + class ViewAllDocumentationsActionItem : SuggestionActionItem { override val displayName: String = "${CodeGPTBundle.get("suggestionActionItem.viewDocumentations.displayName")} →" diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionGroupItems.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionGroupItems.kt index 1eb87dec..f19c2cb5 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionGroupItems.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/item/SuggestionGroupItems.kt @@ -8,18 +8,21 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.Icons import ee.carlrobert.codegpt.settings.GeneralSettings import ee.carlrobert.codegpt.settings.documentation.DocumentationSettings import ee.carlrobert.codegpt.settings.persona.PersonaDetails import ee.carlrobert.codegpt.settings.persona.PersonaSettings import ee.carlrobert.codegpt.settings.service.ServiceType import ee.carlrobert.codegpt.ui.DocumentationDetails +import ee.carlrobert.codegpt.util.GitUtil import ee.carlrobert.codegpt.util.ResourceUtil.getDefaultPersonas import ee.carlrobert.codegpt.util.file.FileUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.time.Instant import java.time.format.DateTimeParseException +import javax.swing.Icon class FileSuggestionGroupItem(private val project: Project) : SuggestionGroupItem { override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.files.displayName") @@ -88,11 +91,7 @@ class PersonaSuggestionGroupItem : SuggestionGroupItem { .toMutableList() return (userCreatedPersonas + getDefaultPersonas()) .filter { - if (searchText.isNullOrEmpty()) { - true - } else { - it.name.contains(searchText, true) - } + searchText.isNullOrEmpty() || it.name.contains(searchText, true) } .map { PersonaActionItem(it) } .take(10) + listOf(CreatePersonaActionItem()) @@ -128,4 +127,21 @@ class DocumentationSuggestionGroupItem : SuggestionGroupItem { } } ?: Instant.EPOCH } -} \ No newline at end of file +} + +class GitSuggestionGroupItem(private val project: Project) : SuggestionGroupItem { + override val displayName: String = CodeGPTBundle.get("suggestionGroupItem.git.displayName") + override val icon: Icon = Icons.VCS + + override suspend fun getSuggestions(searchText: String?): List { + return withContext(Dispatchers.Default) { + GitUtil.getProjectRepository(project)?.let { + GitUtil.getAllRecentCommits(project, it, searchText) + .take(10) + .map { commit -> + GitCommitActionItem(project, commit) + } + } ?: emptyList() + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/renderer/SuggestionItemRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/renderer/SuggestionItemRenderer.kt index 93a60af3..911be208 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/renderer/SuggestionItemRenderer.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/suggestion/renderer/SuggestionItemRenderer.kt @@ -8,6 +8,7 @@ import com.intellij.ui.JBColor import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.EncodingManager import ee.carlrobert.codegpt.settings.persona.PersonaSettings import ee.carlrobert.codegpt.ui.textarea.PromptTextField import ee.carlrobert.codegpt.ui.textarea.suggestion.item.* @@ -133,6 +134,21 @@ class PersonaItemRenderer(textPane: PromptTextField) : BaseItemRenderer(textPane } } +class GitCommitItemRenderer(textPane: PromptTextField) : BaseItemRenderer(textPane) { + override fun render(component: JLabel, value: SuggestionItem): JPanel { + val item = value as GitCommitActionItem + val author = item.gitCommit.author.name + val truncatedAuthor = if (author.length > 32) "${author.take(32)}..." else author + return createPanel( + component, + item.icon, + item.displayName, + "${item.gitCommit.id.asString().take(6)} • $truncatedAuthor", + null, + ) + } +} + class DocumentationItemRenderer(textPane: PromptTextField) : BaseItemRenderer(textPane) { override fun render(component: JLabel, value: SuggestionItem): JPanel { val item = value as DocumentationActionItem @@ -152,6 +168,7 @@ class RendererFactory(private val textPane: PromptTextField) { is FileActionItem -> FileItemRenderer(textPane) is FolderActionItem -> FolderItemRenderer(textPane) is PersonaActionItem -> PersonaItemRenderer(textPane) + is GitCommitActionItem -> GitCommitItemRenderer(textPane) is DocumentationActionItem -> DocumentationItemRenderer(textPane) else -> DefaultItemRenderer(textPane) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt index f05e70c9..bf4889c1 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/GitUtil.kt @@ -1,14 +1,21 @@ package ee.carlrobert.codegpt.util +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.VcsException +import git4idea.GitCommit import git4idea.commands.Git import git4idea.commands.GitCommand import git4idea.commands.GitLineHandler +import git4idea.history.GitHistoryUtils import git4idea.repo.GitRepository +import git4idea.repo.GitRepositoryManager object GitUtil { + private val logger = thisLogger() + @Throws(VcsException::class) @JvmStatic fun getStagedDiff( @@ -51,14 +58,71 @@ object GitUtil { } val commandResult = Git.getInstance().runCommand(handler) - return commandResult.output.filter { - listOf( - "diff --git", - "index ", - "---", - "- ", - "+++" - ).none { prefix -> it.startsWith(prefix) } + return filterDiffOutput(commandResult.output) + } + + @Throws(VcsException::class) + fun getProjectRepository(project: Project): GitRepository? { + val repositoryManager = project.service() + return repositoryManager.getRepositoryForFile(project.workspaceFile) + ?: repositoryManager.repositories.firstOrNull() + } + + @Throws(VcsException::class) + fun getCommitDiff( + project: Project, + gitRepository: GitRepository, + commitHash: String + ): List { + val handler = GitLineHandler(project, gitRepository.root, GitCommand.SHOW) + handler.addParameters( + commitHash, + "--unified=2", + "--no-prefix", + "--no-color" + ) + + val commandResult = Git.getInstance().runCommand(handler) + return filterDiffOutput(commandResult.output) + } + + @Throws(VcsException::class) + fun getAllRecentCommits( + project: Project, + repository: GitRepository, + searchText: String? = "", + limit: Int = 250 + ): List { + val result = mutableListOf() + + try { + GitHistoryUtils + .loadDetails(project, repository.root, { commit -> + if (searchText.isNullOrEmpty()) { + result.add(commit) + } else { + if (commit.id.asString().contains(searchText, true) + || commit.fullMessage.contains(searchText, true) + ) { + result.add(commit) + } + } + }, "-n", "$limit") + } catch (e: VcsException) { + logger.error("Error fetching commit history: {}", e.message) + } + + return result + } + + private fun filterDiffOutput(output: List): List { + return output.filter { + !it.startsWith("diff --git") && + !it.startsWith("index ") && + !it.startsWith("---") && + !it.startsWith("+++") && + !it.startsWith("- ") && + !it.startsWith("commit ") } } } diff --git a/src/main/resources/icons/vcs.svg b/src/main/resources/icons/vcs.svg new file mode 100644 index 00000000..37f8363f --- /dev/null +++ b/src/main/resources/icons/vcs.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/icons/vcs_dark.svg b/src/main/resources/icons/vcs_dark.svg new file mode 100644 index 00000000..d906e8df --- /dev/null +++ b/src/main/resources/icons/vcs_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index c1892cdb..2ba25746 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -265,6 +265,7 @@ suggestionGroupItem.files.displayName=Files suggestionGroupItem.folders.displayName=Folders suggestionGroupItem.personas.displayName=Personas suggestionGroupItem.docs.displayName=Docs +suggestionGroupItem.git.displayName=Git suggestionActionItem.webSearch.displayName=Web suggestionActionItem.viewDocumentations.displayName=View all docs suggestionActionItem.createPersona.displayName=Create new persona