From f483e3e9a82b9bc51f5a343ff7360a10a1363509 Mon Sep 17 00:00:00 2001 From: Dominic Cooney Date: Wed, 29 May 2024 00:05:09 +0900 Subject: [PATCH] Polish the enterprise enhanced context selector (#1669) Fixes #1322, fixes #1425, fixes #1544, fixes #1532, fixes #1542 To summarize the changes: - There are some repo resolution caches to make checking and unchecking faster. - The summary line has been simplified from counting total, ignored, etc. repos to a simple count of repos which will be used (that is, the enabled and not ignored repos, including the automatically included repo if it is not ignored.) - The automatically included repository is represented in the tree view with a "Project repository" label. - There's a separator between the tree view and the rest of the chat panel. There's an expansive tooltip when you hover the separator, but not the tree view so the tooltip does not impede expanding and collapsing the tree view. - The right hand side toolbar is gone, instead, you click on a tree view item to bring up the repo list editor. You can also highlight it with the keyboard and hit "enter". - If you try to enable more than 10 repositories, you get feedback in the form of an error notification. - You can select and deselect repositories and they stay in the repository tree view and are saved in chat state. - The tree view reflects what you wrote in the popup. "Not found" repositories are present with a label. You can delete a repository from the text box to remove it from the tree view. - The contrast and consistency of icons have been improved. - The popup is positioned above the repository list, and is larger. - The intermediate "Repositories" node of the tree view has been removed. Known bugs/caveats: - When selecting/deselecting repositories in the tree view with the keyboard, the item loses focus as the view is reconstructed. - You can add an eleventh repository by specifying 10 repositories that are not the automatically included repository. This one goes up to 11. - Loading a chat with a de-selecting repository that has since been filtered and checking it will result in the "ignored" state appearing. This is because Cody Ignore is applied at late stage of remote repo handling. - Some of the new icons proposed in the design are not incorporated. There are many overlapping versions of the design for this component... I have to draw a line under this and handle any other feedback as follow-ups. - This does not address the https feedback in issue #1322; https URLs are filed in #1354 and will be looked at separately. - Repositories where the entered spec and the resolved name are different may present as duplicates with one "not found." ## Test plan Tested locally ![Screenshot 2024-05-28 at 19 19 09](https://github.com/sourcegraph/jetbrains/assets/55120/1640c6ca-5672-45dc-9f9e-617dfba1966d) --- src/main/java/com/sourcegraph/cody/Icons.java | 2 + .../sourcegraph/cody/chat/AgentChatSession.kt | 24 +- .../com/sourcegraph/cody/chat/ui/Pluralize.kt | 5 +- ...nterpriseEnhancedContextStateController.kt | 341 +++++++++++++++ .../cody/context/RemoteRepoInsight.kt | 58 ++- .../cody/context/RemoteRepoUtils.kt | 8 +- ...otification.kt => ContextNotifications.kt} | 15 + .../ui/ContextRepositoriesCheckboxRenderer.kt | 77 ++-- .../cody/context/ui/ContextTreeNode.kt | 14 +- .../cody/context/ui/EnhancedContextPanel.kt | 399 +++++++++--------- .../context/ui/RemoteRepoPopupController.kt | 8 +- .../cody/history/ChatHistoryPanel.kt | 1 - src/main/resources/CodyBundle.properties | 18 +- .../resources/icons/actions/huge_plus.svg | 3 + .../icons/actions/huge_plus_dark.svg | 3 + src/main/resources/icons/actions/pencil.svg | 3 + .../resources/icons/actions/pencil_dark.svg | 3 + .../resources/icons/repo-host-bitbucket.svg | 2 +- .../icons/repo-host-bitbucket_dark.svg | 1 + .../resources/icons/repo-host-generic.svg | 2 +- .../icons/repo-host-generic_dark.svg | 1 + src/main/resources/icons/repo-host-github.svg | 2 +- .../resources/icons/repo-host-github_dark.svg | 1 + src/main/resources/icons/repo-host-gitlab.svg | 2 +- .../resources/icons/repo-host-gitlab_dark.svg | 1 + src/main/resources/icons/repo-ignored.svg | 4 +- .../resources/icons/repo-ignored_dark.svg | 4 + 27 files changed, 715 insertions(+), 287 deletions(-) create mode 100644 src/main/kotlin/com/sourcegraph/cody/context/EnterpriseEnhancedContextStateController.kt rename src/main/kotlin/com/sourcegraph/cody/context/ui/{RemoteRepoResolutionFailedNotification.kt => ContextNotifications.kt} (73%) create mode 100644 src/main/resources/icons/actions/huge_plus.svg create mode 100644 src/main/resources/icons/actions/huge_plus_dark.svg create mode 100644 src/main/resources/icons/actions/pencil.svg create mode 100644 src/main/resources/icons/actions/pencil_dark.svg create mode 100644 src/main/resources/icons/repo-host-bitbucket_dark.svg create mode 100644 src/main/resources/icons/repo-host-generic_dark.svg create mode 100644 src/main/resources/icons/repo-host-github_dark.svg create mode 100644 src/main/resources/icons/repo-host-gitlab_dark.svg create mode 100644 src/main/resources/icons/repo-ignored_dark.svg diff --git a/src/main/java/com/sourcegraph/cody/Icons.java b/src/main/java/com/sourcegraph/cody/Icons.java index 85b2df1b32..28749a863e 100644 --- a/src/main/java/com/sourcegraph/cody/Icons.java +++ b/src/main/java/com/sourcegraph/cody/Icons.java @@ -9,6 +9,8 @@ public interface Icons { Icon HiImCody = IconLoader.getIcon("/icons/hiImCodyLogo.svg", Icons.class); interface Actions { + Icon Add = IconLoader.getIcon("/icons/actions/huge_plus.svg", Icons.class); + Icon Edit = IconLoader.getIcon("/icons/actions/pencil.svg", Icons.class); Icon Hide = IconLoader.getIcon("/icons/actions/hide.svg", Icons.class); Icon Send = IconLoader.getIcon("/icons/actions/send.svg", Icons.class); Icon DisabledSend = IconLoader.getIcon("/icons/actions/disabledSend.svg", Icons.class); diff --git a/src/main/kotlin/com/sourcegraph/cody/chat/AgentChatSession.kt b/src/main/kotlin/com/sourcegraph/cody/chat/AgentChatSession.kt index 74ed3a59a1..820bf66670 100644 --- a/src/main/kotlin/com/sourcegraph/cody/chat/AgentChatSession.kt +++ b/src/main/kotlin/com/sourcegraph/cody/chat/AgentChatSession.kt @@ -25,16 +25,15 @@ import com.sourcegraph.cody.chat.ui.ChatPanel import com.sourcegraph.cody.commands.CommandId import com.sourcegraph.cody.config.CodyAuthenticationManager import com.sourcegraph.cody.config.RateLimitStateManager -import com.sourcegraph.cody.context.RemoteRepoUtils import com.sourcegraph.cody.error.CodyErrorSubmitter import com.sourcegraph.cody.history.HistoryService import com.sourcegraph.cody.history.state.ChatState +import com.sourcegraph.cody.history.state.EnhancedContextState import com.sourcegraph.cody.history.state.MessageState import com.sourcegraph.cody.vscode.CancellationToken import com.sourcegraph.common.CodyBundle import com.sourcegraph.common.CodyBundle.fmt import com.sourcegraph.telemetry.GraphQlLogger -import com.sourcegraph.vcs.CodebaseName import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException @@ -276,23 +275,10 @@ private constructor( restoreChatSession(agent, chatMessages, chatModelProviderFromState, state.internalId!!) connectionId.getAndSet(newConnectionId) - // Update the Agent-side state. - val remoteRepos = state.enhancedContext?.remoteRepositories - if (remoteRepos != null && - CodyAuthenticationManager.getInstance(project).getActiveAccount()?.isDotcomAccount() == - false) { - RemoteRepoUtils.resolveReposWithErrorNotification( - project, - remoteRepos - .filter { it -> it.isEnabled && it.codebaseName != null } - .map { it -> CodebaseName(it.codebaseName!!) } - .toList()) { resolvedRepos -> - sendWebviewMessage( - WebviewMessage( - command = "context/choose-remote-search-repo", - explicitRepos = resolvedRepos)) - } - .join() + // Update the context view, controller, and Agent-side state. + if (CodyAuthenticationManager.getInstance(project).getActiveAccount()?.isDotcomAccount() == + false) { + chatPanel.contextView.updateFromSavedState(state.enhancedContext ?: EnhancedContextState()) } } diff --git a/src/main/kotlin/com/sourcegraph/cody/chat/ui/Pluralize.kt b/src/main/kotlin/com/sourcegraph/cody/chat/ui/Pluralize.kt index e193652cbd..f736da9228 100644 --- a/src/main/kotlin/com/sourcegraph/cody/chat/ui/Pluralize.kt +++ b/src/main/kotlin/com/sourcegraph/cody/chat/ui/Pluralize.kt @@ -1,8 +1,9 @@ package com.sourcegraph.cody.chat.ui -// Can pluralize "file", "line" and "repo" by adding -s +// Can pluralize "file", "line", "repo" and "repository" fun String.pluralize(count: Int): String = when { count == 1 -> this - else -> "${this}s" + this.endsWith("y") -> this.dropLast(1) + "ies" + else -> this + "s" } diff --git a/src/main/kotlin/com/sourcegraph/cody/context/EnterpriseEnhancedContextStateController.kt b/src/main/kotlin/com/sourcegraph/cody/context/EnterpriseEnhancedContextStateController.kt new file mode 100644 index 0000000000..0158556d6c --- /dev/null +++ b/src/main/kotlin/com/sourcegraph/cody/context/EnterpriseEnhancedContextStateController.kt @@ -0,0 +1,341 @@ +package com.sourcegraph.cody.context + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.sourcegraph.cody.agent.EnhancedContextContextT +import com.sourcegraph.cody.agent.protocol.Repo +import com.sourcegraph.cody.context.RemoteRepoUtils.getRepositories +import com.sourcegraph.cody.context.ui.MAX_REMOTE_REPOSITORY_COUNT +import com.sourcegraph.cody.history.state.EnhancedContextState +import com.sourcegraph.cody.history.state.RemoteRepositoryState +import com.sourcegraph.vcs.CodebaseName +import java.util.concurrent.TimeUnit + +// The ephemeral, in-memory model of enterprise enhanced context state. +private class EnterpriseEnhancedContextModel { + // What the user actually wrote + @Volatile var rawSpec: String = "" + + // `rawSpec` after parsing and de-duping. This defines the order in which to display repositories. + var specified: Set = emptySet() + + // The names of repositories that have been manually deselected. + var manuallyDeselected: Set = emptySet() + + // What the Agent told us it is using for context. + var configured: List = emptyList() + + // Any repository we ever resolved. Used when re-selecting a de-selected repository without + // re-resolving. + val resolvedCache: MutableMap = mutableMapOf() +} + +/** + * Provides the [EnterpriseEnhancedContextStateController] access to chat's representation of + * enhanced context state. There are THREE representations: + * - JetBrains Cody has a bespoke representation saved in its chat history. This is divorced from + * the TypeScript extension's saved chat history :shrug: + * - The agent has a set of repositories that are actually used for enhanced context. This set can + * be read and written, however the agent may add a repository it has picked up and included + * automatically by examining the project. + * - The chat sidebar UI presents a view of enhanced context to the user. (Including a text field in + * a popup, however that is only *read* by the controller so does not appear here--see + * [EnterpriseEnhancedContextStateController.updateRawSpec].) + */ +interface ChatEnhancedContextStateProvider { + /** Updates JetBrains Cody's "chat history" copy of enhanced context state. */ + fun updateSavedState(updater: (EnhancedContextState) -> Unit) + + /** Updates the Agent-side state for the chat. */ + fun updateAgentState(repos: List) + + /** Pushes a UI update to the chat side panel. */ + fun updateUI(repos: List) + + /** Displays a message that remote repository resolution failed. */ + fun notifyRemoteRepoResolutionFailed() + + /** Displays a message that the user has reached the maximum number of remote repositories. */ + fun notifyRemoteRepoLimit() +} + +/** + * Reconciles the multiple, asynchronously updated copies of enhanced context state. + * + * Changes follow this flow: + * 1. A chat is restored ([loadFromChatState]) which synthesizes a [rawSpec] and a + * `model.manuallyDeselected` set. + * 2. When the raw spec is updated, we parse it and produce a "speculative set of repos" + * ([updateSpeculativeRepos]) These have not been resolved by the backend and may be totally + * bogus. + * 3. When the speculative repos are resolved ([onResolvedRepos]) we can filter the + * `model.manuallyDeselected` ones and request the Agent to focus on a set of repositories + * (`chat.updateAgentState`). + * 4. When the Agent has updated its state ([onAgentStateUpdated]) we finally learn which + * repositories are actually used, whether a repository is implicitly included by the Agent based + * on the project, and whether a repository is filtered by Context Filters. + * 5. Finally, we can [updateUI]. + * + * When the user updates the raw spec, the same process happens from step 2 to step 4, however we + * also `chat.updateSavedState` to save the changes to the JetBrains-side copy of chat history. + * + * When the user checks and unchecks repositories, we already have all the resolved repository + * details. We just update the JetBrains-side copy of chat history (`chat.updateSavedState`) and do + * the `chat.updateAgentState` -> [onAgentStateUpdated] flow. + */ +class EnterpriseEnhancedContextStateController( + val project: Project, + val chat: ChatEnhancedContextStateProvider +) { + private val logger = Logger.getInstance(EnterpriseEnhancedContextStateController::class.java) + private val model_ = EnterpriseEnhancedContextModel() + private var epoch = 0 + + val rawSpec: String + get(): String = model_.rawSpec + + private fun withModel(f: (EnterpriseEnhancedContextModel) -> T): T { + assert(!ApplicationManager.getApplication().isDispatchThread) { + "Must not use model from EDT, it may block" + } + synchronized(model_) { + return f(model_) + } + } + + /** + * Loads the set of repositories from the JetBrains-side copy of chat history and starts the + * process of resolving the mentioned repositories, configuring Agent to use them, and eventually + * updating the UI. + */ + fun loadFromChatState(remoteRepositories: List?) { + val cleanedRepos = + remoteRepositories?.filter { it.codebaseName != null }?.toSet()?.toList() ?: emptyList() + + // Start trying to resolve these cached repos. Note, we try to resolve everything, even + // deselected repos. + ApplicationManager.getApplication().executeOnPooledThread { + // Remember which repositories have been manually deselected. + withModel { model -> + model.rawSpec = cleanedRepos.map { it.codebaseName }.joinToString("\n") + model.manuallyDeselected = + cleanedRepos.filter { !it.isEnabled }.mapNotNull { it.codebaseName }.toSet() + } + + updateSpeculativeRepos(cleanedRepos.mapNotNull { it.codebaseName }) + } + } + + /** + * Updates the text spec of the repository list when it is edited by the user. This does not reset + * the manually deselected set because the user may have edited an unrelated part of the spec. + * However, if a repository is removed from the spec, we remove it from the manually deselected + * set for it to be selected by default if it is re-added later. This saves the updated repository + * list to the JetBrains-side copy of chat history. + */ + fun updateRawSpec(newSpec: String) { + val speculative = withModel { model -> + model.rawSpec = newSpec + val speculative = newSpec.split(Regex("""\s+""")).filter { it != "" }.toSet().toList() + + // If a repository name has been removed from the list of speculative repos, then forget that + // it was manually deselected in order for it to be default selected if it is added back. + + // TODO: Improve the accuracy of removals when there's an Agent API that maps specified name + // -> + // resolved name. + // Today we only have names go in and a set of repositories come out, in different + // (alphabetical) order. + model.manuallyDeselected = + model.manuallyDeselected.filter { speculative.contains(it) }.toSet() + speculative + } + updateSpeculativeRepos(speculative) + } + + // Builds the initial list of repositories and kicks off the process of resolving them. + private fun updateSpeculativeRepos(repos: List) { + assert(!ApplicationManager.getApplication().isDispatchThread) { + "updateSpeculativeRepos should not be used on EDT, it may block" + } + + var thisEpoch = + synchronized(this) { + withModel { model -> model.specified = repos.toSet() } + ++epoch + } + + // Consult the repo resolution cache. + val resolved = mutableSetOf() + val toResolve = mutableSetOf() + withModel { model -> + for (repo in repos) { + val cached = model.resolvedCache[repo] + when { + cached == null -> toResolve.add(repo) + else -> resolved.add(cached) + } + } + } + + // Remotely resolve the repositories that we couldn't resolve locally. + if (toResolve.size > 0) { + val newlyResolvedRepos = + getRepositories(project, toResolve.map { CodebaseName(it) }.toList()) + .completeOnTimeout(emptyList(), 15, TimeUnit.SECONDS) + .get() + + // Update the cache of resolved repositories. + withModel { model -> model.resolvedCache.putAll(newlyResolvedRepos.associateBy { it.name }) } + + resolved.addAll(newlyResolvedRepos) + } + + synchronized(this) { + if (epoch != thisEpoch) { + // We've kicked off another update in the meantime, so run with that one. + return + } + if (repos.isNotEmpty() && resolved.isEmpty()) { + chat.notifyRemoteRepoResolutionFailed() + return + } + updateSavedState() + onResolvedRepos(resolved.toList()) + } + } + + private fun onResolvedRepos(repos: List) { + var resolvedRepos = repos.associateBy { repo -> repo.name } + + // Update the Agent state. This eventually produces `updateFromAgent` which triggers the tree + // view update. + val reposToSendToAgent = withModel { model -> + model.specified + .mapNotNull { repoSpecName -> resolvedRepos[repoSpecName] } + .filter { !model.manuallyDeselected.contains(it.name) } + .take(MAX_REMOTE_REPOSITORY_COUNT) + } + chat.updateAgentState(reposToSendToAgent) + } + + fun updateFromAgent(enhancedContextStatus: EnhancedContextContextT) { + // Collect the configured repositories from the Agent reported state. + val repos = mutableListOf() + + for (group in enhancedContextStatus.groups) { + val provider = group.providers.firstOrNull() ?: continue + val name = group.displayName + val id = provider.id ?: continue + val enablement = + when { + provider.state == "ready" -> RepoSelectionStatus.SELECTED + else -> RepoSelectionStatus.DESELECTED + } + val ignored = provider.isIgnored == true + val inclusion = + when (provider.inclusion) { + "auto" -> RepoInclusion.AUTO + "manual" -> RepoInclusion.MANUAL + else -> RepoInclusion.MANUAL + } + repos.add(RemoteRepo(name, id, enablement, isIgnored = ignored, inclusion)) + } + + withModel { model -> model.configured = repos } + updateUI() + } + + private fun updateUI() { + val usedRepos: MutableMap = mutableMapOf() + val repos = mutableListOf() + + withModel { model -> + // Compute the merged representation of repositories. + usedRepos.putAll(model.configured.associateBy { it.name }) + + // Visit the repositories in the order specified by the user. + repos.addAll( + model.specified.map { + usedRepos.getOrDefault( + it, + // If the repo was manually deselected, then we show it as de-selected. + // The repo was not manually deselected, yet isn't in the configured repos, hence it + // is not found. + // TODO: We could speculatively consult Cody Ignore to see if the deselected repo + // *would* have been ignored. + RemoteRepo( + it, + null, + if (model.manuallyDeselected.contains(it)) { + RepoSelectionStatus.DESELECTED + } else { + RepoSelectionStatus.NOT_FOUND + }, + isIgnored = false, + RepoInclusion.MANUAL)) + }) + } + + // Finally, if there are any remaining repos configured by the agent which are not used, + // represent them now. + repos.addAll(usedRepos.values.filter { !repos.contains(it) }) + + // ...and push the list to the UI. + chat.updateUI(repos) + } + + fun setRepoEnabledInContextState(repoName: String, enabled: Boolean) { + withModel { model -> + val atLimit = model.configured.count { it.isEnabled } >= MAX_REMOTE_REPOSITORY_COUNT + val repos = model.configured.map { Repo(it.name, it.id!!) }.toMutableList() + + if (enabled) { + if (atLimit) { + chat.notifyRemoteRepoLimit() + return@withModel + } + model.manuallyDeselected = model.manuallyDeselected.filter { it != repoName }.toSet() + val repoToAdd = synchronized(model.resolvedCache) { model.resolvedCache[repoName] } + if (repoToAdd == null) { + logger.warn("failed to find repo $repoName in the resolved cache; will not enable it") + return@withModel + } + repos.add(repoToAdd) + } else { + model.manuallyDeselected = model.manuallyDeselected.plus(repoName) + repos.removeIf { it.name == repoName } + } + updateSavedState() + + // Update the Agent state. This eventually produces `updateFromAgent` which triggers the tree + // view update. + chat.updateAgentState(repos) + } + } + + // Pushes a state update to the JetBrains chat history copy of the enhanced context state. This + // simply takes + // whatever the user specified (`model.specified`) and saves it, along with which repos were + // deselected + // (`model.manuallyDeselected`). + private fun updateSavedState() { + val reposToWriteToState = withModel { model -> + model.specified.map { repoSpecName -> + RemoteRepositoryState().apply { + codebaseName = repoSpecName + // Note, we don't limit to MAX_REMOTE_REPOSITORY_COUNT here. We may raise or lower + // that limit in future versions anyway, so we just record what is manually deselected + // and apply the limit when updating Agent-side state. + isEnabled = !model.manuallyDeselected.contains(repoSpecName) + } + } + } + + chat.updateSavedState { state -> + state.remoteRepositories.clear() + state.remoteRepositories.addAll(reposToWriteToState) + } + } +} diff --git a/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoInsight.kt b/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoInsight.kt index c8f8ace9fc..e450f2af45 100644 --- a/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoInsight.kt +++ b/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoInsight.kt @@ -1,10 +1,6 @@ package com.sourcegraph.cody.context -import com.intellij.codeInsight.completion.CompletionContributor -import com.intellij.codeInsight.completion.CompletionParameters -import com.intellij.codeInsight.completion.CompletionProvider -import com.intellij.codeInsight.completion.CompletionResultSet -import com.intellij.codeInsight.completion.CompletionType +import com.intellij.codeInsight.completion.* import com.intellij.codeInsight.lookup.LookupElementBuilder import com.intellij.extapi.psi.ASTWrapperPsiElement import com.intellij.extapi.psi.PsiFileBase @@ -20,8 +16,7 @@ import com.intellij.lexer.LexerPosition import com.intellij.openapi.editor.Editor import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.LanguageFileType -import com.intellij.openapi.progress.blockingContext -import com.intellij.openapi.progress.runBlockingCancellable +import com.intellij.openapi.progress.* import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange @@ -49,24 +44,48 @@ enum class RepoInclusion { MANUAL, } +enum class RepoSelectionStatus { + /** The user manually deselected the repository. */ + DESELECTED, + /** Remote repo search did not find the repo (so it is disabled.) */ + NOT_FOUND, + /** The repo has been found and is selected. */ + SELECTED, +} + data class RemoteRepo( val name: String, - var isEnabled: Boolean? = null, - val isIgnored: Boolean? = null, - val inclusion: RepoInclusion? = null + /** + * Null in the case of "not found" repos, or manually deselected repos we did not try to find. + */ + val id: String?, + val selectionStatus: RepoSelectionStatus, + val isIgnored: Boolean, + val inclusion: RepoInclusion, ) { + val isEnabled: Boolean + get() = selectionStatus == RepoSelectionStatus.SELECTED && !isIgnored + val displayName: String get() = name.substring(name.indexOf('/') + 1) // Note, works for names without / => full name. - val icon: Icon? + val icon: Icon get() = when { - isIgnored == true -> Icons.RepoIgnored - name.startsWith("github.com/") -> Icons.RepoHostGitHub - name.startsWith("gitlab.com/") -> Icons.RepoHostGitlab - name.startsWith("bitbucket.org/") -> Icons.RepoHostBitbucket - else -> Icons.RepoHostGeneric + isIgnored -> Icons.RepoIgnored + else -> iconForName(name) } + + companion object { + fun iconForName(name: String): Icon { + return when { + name.startsWith("github.com/") -> Icons.RepoHostGitHub + name.startsWith("gitlab.com/") -> Icons.RepoHostGitlab + name.startsWith("bitbucket.org/") -> Icons.RepoHostBitbucket + else -> Icons.RepoHostGeneric + } + } + } } val RemoteRepoLanguage = object : Language("SourcegraphRemoteRepoList") {} @@ -365,9 +384,8 @@ class RemoteRepoCompletionContributor : CompletionContributor(), DumbAware { ) { val searcher = RemoteRepoSearcher.getInstance(parameters.position.project) // We use original position, if present, because it does not have the "helpful" dummy - // text - // "IntellijIdeaRulezzz". Because we do a fuzzy match, we use the whole element as the - // query. + // text "IntellijIdeaRulezzz". Because we do a fuzzy match, we use the whole element + // as the query. val element = parameters.originalPosition val query = if (element?.elementType == RemoteRepoTokenType.REPO) { @@ -391,7 +409,7 @@ class RemoteRepoCompletionContributor : CompletionContributor(), DumbAware { blockingContext { // addElement uses ProgressManager.checkCancelled for (repo in repos) { prefixedResult.addElement( - LookupElementBuilder.create(repo).withIcon(RemoteRepo(repo).icon)) + LookupElementBuilder.create(repo).withIcon(RemoteRepo.iconForName(repo))) } } } diff --git a/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoUtils.kt b/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoUtils.kt index 8e1e35f981..559ea02280 100644 --- a/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoUtils.kt +++ b/src/main/kotlin/com/sourcegraph/cody/context/RemoteRepoUtils.kt @@ -6,7 +6,6 @@ import com.intellij.openapi.project.Project import com.sourcegraph.cody.agent.CodyAgentService import com.sourcegraph.cody.agent.protocol.GetRepoIdsParam import com.sourcegraph.cody.agent.protocol.Repo -import com.sourcegraph.cody.context.ui.MAX_REMOTE_REPOSITORY_COUNT import com.sourcegraph.cody.context.ui.RemoteRepoResolutionFailedNotification import com.sourcegraph.vcs.CodebaseName import java.util.concurrent.CompletableFuture @@ -39,9 +38,8 @@ object RemoteRepoUtils { } /** - * Resolves the repositories named in `repos` and runs `callback` with the first - * `MAX_REMOTE_REPOSITORY_COUNT` of them. If remote repo resolution fails, displays an error - * message instead. + * Resolves the repositories named in `repos` and runs `callback` with the result. If remote repo + * resolution fails, displays an error message instead. */ fun resolveReposWithErrorNotification( project: Project, @@ -63,7 +61,7 @@ object RemoteRepoUtils { runInEdt { RemoteRepoResolutionFailedNotification().notify(project) } return@thenApply } - callback(resolvedRepos.take(MAX_REMOTE_REPOSITORY_COUNT)) + callback(resolvedRepos) } } } diff --git a/src/main/kotlin/com/sourcegraph/cody/context/ui/RemoteRepoResolutionFailedNotification.kt b/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextNotifications.kt similarity index 73% rename from src/main/kotlin/com/sourcegraph/cody/context/ui/RemoteRepoResolutionFailedNotification.kt rename to src/main/kotlin/com/sourcegraph/cody/context/ui/ContextNotifications.kt index 50ee142126..b8f4b2b524 100644 --- a/src/main/kotlin/com/sourcegraph/cody/context/ui/RemoteRepoResolutionFailedNotification.kt +++ b/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextNotifications.kt @@ -8,6 +8,7 @@ import com.intellij.notification.impl.NotificationFullContent import com.intellij.openapi.actionSystem.AnActionEvent import com.sourcegraph.Icons import com.sourcegraph.common.CodyBundle +import com.sourcegraph.common.CodyBundle.fmt import com.sourcegraph.common.NotificationGroups class RemoteRepoResolutionFailedNotification : @@ -37,3 +38,17 @@ class RemoteRepoResolutionFailedNotification : val ignore = CodyBundle.getString("context-panel.remote-repo.error-resolution-failed.ignore") } } + +class RemoteRepoLimitNotification : + Notification( + NotificationGroups.SOURCEGRAPH_ERRORS, + CodyBundle.getString("context-panel.remote-repo.error-too-many-repositories.tooltip"), + CodyBundle.getString("context-panel.remote-repo.error-too-many-repositories") + .fmt(MAX_REMOTE_REPOSITORY_COUNT.toString()), + NotificationType.WARNING), + NotificationFullContent { + + init { + icon = Icons.RepoHostGeneric + } +} diff --git a/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextRepositoriesCheckboxRenderer.kt b/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextRepositoriesCheckboxRenderer.kt index d607118e84..180277e9ea 100644 --- a/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextRepositoriesCheckboxRenderer.kt +++ b/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextRepositoriesCheckboxRenderer.kt @@ -5,8 +5,10 @@ import com.intellij.ui.CheckboxTree import com.intellij.ui.CheckedTreeNode import com.intellij.ui.SimpleTextAttributes import com.intellij.util.ui.ThreeStateCheckBox +import com.sourcegraph.cody.Icons import com.sourcegraph.cody.chat.ui.pluralize import com.sourcegraph.cody.context.RepoInclusion +import com.sourcegraph.cody.context.RepoSelectionStatus import com.sourcegraph.common.CodyBundle import com.sourcegraph.common.CodyBundle.fmt import java.util.concurrent.atomic.AtomicBoolean @@ -39,25 +41,30 @@ class ContextRepositoriesCheckboxRenderer(private val enhancedContextEnabled: At // Enterprise context node renderers - is ContextTreeEnterpriseRootNode -> { - // Compute a complicated label counting repositories, for example: - // *Chat Context* 1 Repo on example.com - // *Chat Context* 2 Repos - 1 ignored on example.com - val ignoredRejoinder = + is ContextTreeEditReposNode -> { + toolTipText = "" + myCheckbox.isVisible = false + textRenderer.appendHTML( + CodyBundle.getString( + when { + node.hasRemovableRepos -> "context-panel.tree.node-edit-repos.label-edit" + else -> "context-panel.tree.node-edit-repos.label-add" + }) + .fmt(style), + SimpleTextAttributes.REGULAR_ATTRIBUTES) + textRenderer.icon = when { - node.numIgnoredRepos > 0 -> - CodyBundle.getString("context-panel.tree.node-chat-context.detail-ignored-repos") - .fmt(node.numIgnoredRepos.toString()) - else -> "" + node.hasRemovableRepos -> Icons.Actions.Edit + else -> Icons.Actions.Add } + } + is ContextTreeEnterpriseRootNode -> { textRenderer.appendHTML( CodyBundle.getString("context-panel.tree.node-chat-context.detailed") .fmt( style, - node.numRepos.toString(), - "Repo".pluralize(node.numRepos), - ignoredRejoinder, - node.endpointName), + node.numActiveRepos.toString(), + "repository".pluralize(node.numActiveRepos)), SimpleTextAttributes.REGULAR_ATTRIBUTES) // The root element controls enhanced context which includes editor selection, etc. Do not // display unchecked/bar even if the child repos are unchecked. @@ -70,38 +77,54 @@ class ContextRepositoriesCheckboxRenderer(private val enhancedContextEnabled: At toolTipText = "" myCheckbox.toolTipText = "" } - is ContextTreeRemotesNode -> { - textRenderer.append( - CodyBundle.getString("context-panel.tree.node-remote-repositories"), - SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) - myCheckbox.isVisible = false - } is ContextTreeRemoteRepoNode -> { val isEnhancedContextEnabled = enhancedContextEnabled.get() - textRenderer.appendHTML(node.repo.displayName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + textRenderer.appendHTML( + CodyBundle.getString("context-panel.tree.node-remote-repo.label") + .fmt( + style, + node.repo.name, + when { + // TODO: Handle missing remote repos with a "not found" string + node.repo.inclusion == RepoInclusion.AUTO && node.repo.isIgnored -> + CodyBundle.getString("context-panel.tree.node-remote-repo.auto-ignored") + node.repo.inclusion == RepoInclusion.AUTO -> + CodyBundle.getString("context-panel.tree.node-remote-repo.auto") + node.repo.isIgnored -> + CodyBundle.getString("context-panel.tree.node-remote-repo.ignored") + node.repo.selectionStatus == RepoSelectionStatus.NOT_FOUND -> + CodyBundle.getString("context-panel.tree.node-remote-repo.not-found") + else -> "" + }), + SimpleTextAttributes.REGULAR_ATTRIBUTES) + textRenderer.icon = node.repo.icon + toolTipText = when { - node.repo.isIgnored == true -> - CodyBundle.getString("context-panel.tree.node-ignored.tooltip") + node.repo.isIgnored -> CodyBundle.getString("context-panel.tree.node-ignored.tooltip") node.repo.inclusion == RepoInclusion.AUTO -> CodyBundle.getString("context-panel.tree.node-auto.tooltip") else -> node.repo.name } myCheckbox.state = when { - isEnhancedContextEnabled && - node.repo.isEnabled == true && - node.repo.isIgnored != true -> ThreeStateCheckBox.State.SELECTED - node.repo.isEnabled == true -> ThreeStateCheckBox.State.DONT_CARE + isEnhancedContextEnabled && node.repo.isEnabled && !node.repo.isIgnored -> + ThreeStateCheckBox.State.SELECTED + node.repo.isEnabled -> ThreeStateCheckBox.State.DONT_CARE else -> ThreeStateCheckBox.State.NOT_SELECTED } - myCheckbox.isEnabled = isEnhancedContextEnabled && node.repo.inclusion != RepoInclusion.AUTO + myCheckbox.isEnabled = + isEnhancedContextEnabled && + node.repo.inclusion != RepoInclusion.AUTO && + node.repo.selectionStatus != RepoSelectionStatus.NOT_FOUND myCheckbox.toolTipText = when { node.repo.inclusion == RepoInclusion.AUTO -> CodyBundle.getString("context-panel.tree.node-auto.tooltip") + node.repo.selectionStatus == RepoSelectionStatus.NOT_FOUND -> + CodyBundle.getString("context-panel.tree.node-remote-repo.not-found") else -> CodyBundle.getString("context-panel.tree.node.checkbox.remove-tooltip") } } diff --git a/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextTreeNode.kt b/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextTreeNode.kt index dcbf80a79a..6853b293a2 100644 --- a/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextTreeNode.kt +++ b/src/main/kotlin/com/sourcegraph/cody/context/ui/ContextTreeNode.kt @@ -32,18 +32,14 @@ class ContextTreeLocalRepoNode(val project: Project, isEnhancedContextEnabled: A ContextTreeLocalNode(project, isEnhancedContextEnabled) /** Enterprise context selector tree, root node. */ -open class ContextTreeEnterpriseRootNode( - var endpointName: String, - var numRepos: Int, - var numIgnoredRepos: Int, - onSetChecked: (Boolean) -> Unit -) : +open class ContextTreeEnterpriseRootNode(var numActiveRepos: Int, onSetChecked: (Boolean) -> Unit) : ContextTreeNode( Object(), onSetChecked) // TreePaths depend on user objects; Object() ensures uniqueness. -/** Enterprise context selector tree, parent node of all remote repositories. */ -class ContextTreeRemotesNode() : - ContextTreeNode(Object()) // TreePaths depend on user objects; Object() ensures uniqueness. +// TODO: Can we remove onActivate if we remove the toolbar? +/** Enterprise context selector tree, a node to trigger editing the repository list. */ +class ContextTreeEditReposNode(var hasRemovableRepos: Boolean, val onActivate: () -> Unit) : + ContextTreeNode(Object()) /** Enterprise context selector tree, a specific remote repository. */ class ContextTreeRemoteRepoNode(val repo: RemoteRepo, onSetChecked: (Boolean) -> Unit) : diff --git a/src/main/kotlin/com/sourcegraph/cody/context/ui/EnhancedContextPanel.kt b/src/main/kotlin/com/sourcegraph/cody/context/ui/EnhancedContextPanel.kt index 09aefe23c7..59e3fccf8b 100644 --- a/src/main/kotlin/com/sourcegraph/cody/context/ui/EnhancedContextPanel.kt +++ b/src/main/kotlin/com/sourcegraph/cody/context/ui/EnhancedContextPanel.kt @@ -8,34 +8,38 @@ import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.Project import com.intellij.openapi.ui.VerticalFlowLayout import com.intellij.openapi.ui.getTreePath +import com.intellij.openapi.ui.popup.JBPopup import com.intellij.ui.CheckboxTree import com.intellij.ui.CheckboxTreeBase import com.intellij.ui.CheckedTreeNode +import com.intellij.ui.TitledSeparator +import com.intellij.ui.ToolbarDecorator import com.intellij.ui.ToolbarDecorator.createDecorator +import com.intellij.ui.awt.RelativePoint import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.vcs.commit.NonModalCommitPanel.Companion.showAbove import com.sourcegraph.cody.agent.EnhancedContextContextT import com.sourcegraph.cody.agent.WebviewMessage +import com.sourcegraph.cody.agent.protocol.Repo import com.sourcegraph.cody.chat.ChatSession import com.sourcegraph.cody.config.CodyAuthenticationManager -import com.sourcegraph.cody.context.RemoteRepo -import com.sourcegraph.cody.context.RemoteRepoUtils -import com.sourcegraph.cody.context.RepoInclusion +import com.sourcegraph.cody.context.* import com.sourcegraph.cody.history.HistoryService import com.sourcegraph.cody.history.state.EnhancedContextState -import com.sourcegraph.cody.history.state.RemoteRepositoryState import com.sourcegraph.common.CodyBundle import com.sourcegraph.common.CodyBundle.fmt -import com.sourcegraph.vcs.CodebaseName +import java.awt.BorderLayout import java.awt.Dimension -import java.util.concurrent.TimeUnit +import java.awt.Point +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import java.util.concurrent.atomic.AtomicBoolean -import javax.swing.BorderFactory -import javax.swing.JComponent -import javax.swing.JPanel +import javax.swing.* import javax.swing.event.TreeExpansionEvent import javax.swing.event.TreeExpansionListener import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.TreeSelectionModel import kotlin.math.max /** @@ -118,26 +122,19 @@ constructor(protected val project: Project, protected val chatSession: ChatSessi protected val tree = run { val checkPolicy = createCheckboxPolicy() object : - CheckboxTree( - ContextRepositoriesCheckboxRenderer(enhancedContextEnabled), treeRoot, checkPolicy) { - // When collapsed, the horizontal scrollbar obscures the Chat Context summary & checkbox. - // Prefer to clip. Users can resize the sidebar if desired. - override fun getScrollableTracksViewportWidth(): Boolean = true - } + CheckboxTree( + ContextRepositoriesCheckboxRenderer(enhancedContextEnabled), + treeRoot, + checkPolicy) { + // When collapsed, the horizontal scrollbar obscures the Chat Context summary & checkbox. + // Prefer to clip. Users can resize the sidebar if desired. + override fun getScrollableTracksViewportWidth(): Boolean = true + } + .apply { selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION } } protected abstract fun createCheckboxPolicy(): CheckboxTreeBase.CheckPolicy - /** The toolbar decorator component. */ - protected val toolbar = run { - createDecorator(tree) - .disableUpDownActions() - .setToolbarPosition(ActionToolbarPosition.RIGHT) - .setVisibleRowCount(1) - .setScrollPaneBorder(BorderFactory.createEmptyBorder()) - .setToolbarBorder(BorderFactory.createEmptyBorder()) - } - init { layout = VerticalFlowLayout(VerticalFlowLayout.BOTTOM, 0, 0, true, false) tree.model = treeModel @@ -173,23 +170,7 @@ constructor(protected val project: Project, protected val chatSession: ChatSessi /** * Adjusts the layout to accommodate the expanded rows in the treeview, and revalidates layout. */ - @RequiresEdt - protected fun resize() { - val padding = 5 - // Set the minimum size to accommodate at least one toolbar button and an overflow ellipsis. - // Because the buttons - // are approximately square, use the toolbar width as a proxy for the button height. - val toolbarButtonHeight = toolbar.actionsPanel.preferredSize.width - val preferredSizeNumVisibleButtons = 1 - panel.preferredSize = - Dimension( - 0, - padding + - max( - tree.rowCount * tree.rowHeight, - preferredSizeNumVisibleButtons * toolbarButtonHeight)) - panel.parent?.revalidate() - } + @RequiresEdt abstract fun resize() @RequiresEdt private fun expandAllNodes(rowCount: Int = tree.rowCount) { @@ -203,195 +184,202 @@ constructor(protected val project: Project, protected val chatSession: ChatSessi } abstract fun updateFromAgent(enhancedContextStatus: EnhancedContextContextT) + + abstract fun updateFromSavedState(state: EnhancedContextState) } class EnterpriseEnhancedContextPanel(project: Project, chatSession: ChatSession) : EnhancedContextPanel(project, chatSession) { - // Cache the raw user input so the user can reopen the popup to make corrections without starting - // from scratch. - private var rawSpec: String = "" + companion object { + fun JBPopup.showAbove(component: JComponent) { + val northWest = RelativePoint(component, Point(0, -this.size.height)) + show(northWest) + } + + private const val ENTER_MAP_KEY = "enter" + } + + // TODO: We need to kick off setting the agent state with + // controller.loadFrom...(getContextState()) etc. + private var controller = + EnterpriseEnhancedContextStateController( + project, + object : ChatEnhancedContextStateProvider { + override fun updateSavedState(modifyContext: (EnhancedContextState) -> Unit) { + runInEdt { updateContextState(modifyContext) } + } + + override fun updateAgentState(repos: List) { + chatSession.sendWebviewMessage( + WebviewMessage( + command = "context/choose-remote-search-repo", explicitRepos = repos)) + } + + override fun updateUI(repos: List) { + runInEdt { updateTree(repos) } + } + + override fun notifyRemoteRepoResolutionFailed() = runInEdt { + RemoteRepoResolutionFailedNotification().notify(project) + } + + override fun notifyRemoteRepoLimit() = runInEdt { + RemoteRepoLimitNotification().notify(project) + } + }) + + private var endpointName: String = "" + + private val repoPopupController = + RemoteRepoPopupController(project).apply { + onAccept = { spec -> + ApplicationManager.getApplication().executeOnPooledThread { + controller.updateRawSpec(spec) + } + } + } + + init { + tree.inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ENTER_MAP_KEY) + tree.actionMap.put( + ENTER_MAP_KEY, + object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + repoPopupController + .createPopup(tree.width, endpointName, controller.rawSpec) + .showAbove(tree) + } + }) + + tree.addMouseListener( + object : MouseAdapter() { + fun targetForEvent(e: MouseEvent): Any? = + tree.getClosestPathForLocation(e.x, e.y)?.lastPathComponent + + // We cache the target of the mouse press, so that if the tree expands before the click + // event is generated, we can detect the mouse click event is on a different node and + // suppress the popup. + private var pressedTarget: Any? = null + + override fun mousePressed(e: MouseEvent) { + super.mousePressed(e) + pressedTarget = targetForEvent(e) + } + + override fun mouseClicked(e: MouseEvent) { + var clickTarget = targetForEvent(e) + if (e.clickCount == 1 && + e.button == MouseEvent.BUTTON1 && + pressedTarget === clickTarget && + clickTarget is ContextTreeEditReposNode) { + repoPopupController + .createPopup(tree.width, endpointName, controller.rawSpec) + .showAbove(tree) + } + } + }) + } @RequiresEdt override fun createPanel(): JComponent { - toolbar.setEditActionName(CodyBundle.getString("context-panel.button.edit-repositories")) - toolbar.setEditAction { - val controller = RemoteRepoPopupController(project) - controller.onAccept = { spec -> - rawSpec = spec - ApplicationManager.getApplication().executeOnPooledThread { applyRepoSpec(spec) } - } + val separator = TitledSeparator(CodyBundle.getString("chat.enhanced_context.title"), tree) + HelpTooltip() + .setTitle(CodyBundle.getString("context-panel.tree.help-tooltip.title")) + .setDescription( + CodyBundle.getString("context-panel.tree.help-tooltip.description") + .fmt(MAX_REMOTE_REPOSITORY_COUNT.toString())) + .setLink(CodyBundle.getString("context-panel.tree.help-tooltip.link.text")) { + BrowserUtil.open(CodyBundle.getString("context-panel.tree.help-tooltip.link.href")) + } + .setLocation(HelpTooltip.Alignment.LEFT) + .setInitialDelay( + 1500) // Tooltip can interfere with the treeview, so cool off on showing it. + .installOn(separator) - val popup = controller.createPopup(tree.width, rawSpec) - popup.showAbove(tree) - } - return toolbar.createPanel() + val panel = JPanel() + panel.layout = BorderLayout() + panel.add(separator, BorderLayout.NORTH) + panel.add(tree, BorderLayout.CENTER) + return panel + } + + override fun resize() { + val padding = 5 + tree.preferredSize = Dimension(0, padding + tree.rowCount * tree.rowHeight) + panel.parent?.revalidate() } override fun createCheckboxPolicy(): CheckboxTreeBase.CheckPolicy = CheckboxTreeBase.CheckPolicy( - /* checkChildrenWithCheckedParent = */ true, - /* uncheckChildrenWithUncheckedParent = */ true, - /* checkParentWithCheckedChild = */ true, + /* checkChildrenWithCheckedParent = */ false, + /* uncheckChildrenWithUncheckedParent = */ false, + /* checkParentWithCheckedChild = */ false, /* uncheckParentWithUncheckedChild = */ false) override fun updateFromAgent(enhancedContextStatus: EnhancedContextContextT) { - val repos = mutableListOf() - - for (group in enhancedContextStatus.groups) { - val provider = group.providers.firstOrNull() ?: continue - val name = group.displayName - val enabled = provider.state == "ready" - val ignored = provider.isIgnored == true - val inclusion = - when (provider.inclusion) { - "auto" -> RepoInclusion.AUTO - "explicit" -> RepoInclusion.MANUAL - else -> null - } - repos.add(RemoteRepo(name, isEnabled = enabled, isIgnored = ignored, inclusion = inclusion)) + ApplicationManager.getApplication().executeOnPooledThread { + controller.updateFromAgent(enhancedContextStatus) } + } - runInEdt { - updateTree(repos) - resize() - } + override fun updateFromSavedState(state: EnhancedContextState) { + controller.loadFromChatState(state.remoteRepositories) } - private val remotesNode = ContextTreeRemotesNode() private val contextRoot = object : - ContextTreeEnterpriseRootNode( - "", 0, 0, { checked -> enhancedContextEnabled.set(checked) }) { + ContextTreeEnterpriseRootNode(0, { checked -> enhancedContextEnabled.set(checked) }) { override fun isChecked(): Boolean { return enhancedContextEnabled.get() } } - init { - val contextState = getContextState() - - val cleanedRepos = - contextState?.remoteRepositories?.filter { it.codebaseName != null }?.toSet()?.toList() - ?: emptyList() - rawSpec = cleanedRepos.map { it.codebaseName }.joinToString("\n") + private val editReposNode = + ContextTreeEditReposNode(false) { + val popup = repoPopupController.createPopup(tree.width, endpointName, controller.rawSpec) + popup.showAbove(tree) + } - val endpoint = + init { + controller.loadFromChatState(getContextState()?.remoteRepositories) + endpointName = CodyAuthenticationManager.getInstance(project).getActiveAccount()?.server?.displayName ?: CodyBundle.getString("context-panel.remote-repo.generic-endpoint-name") - contextRoot.endpointName = endpoint - contextRoot.add(remotesNode) treeRoot.add(contextRoot) treeModel.reload() resize() - - HelpTooltip() - .setTitle(CodyBundle.getString("context-panel.tree.help-tooltip.title")) - .setDescription( - CodyBundle.getString("context-panel.tree.help-tooltip.description") - .fmt(MAX_REMOTE_REPOSITORY_COUNT.toString(), endpoint)) - .setLink(CodyBundle.getString("context-panel.tree.help-tooltip.link.text")) { - BrowserUtil.open(CodyBundle.getString("context-panel.tree.help-tooltip.link.href")) - } - .setLocation(HelpTooltip.Alignment.LEFT) - .setInitialDelay( - 1500) // Tooltip can interfere with the treeview, so cool off on showing it. - .installOn(tree) - - // Update the Agent-side state for this chat. - val enabledRepos = cleanedRepos.filter { it.isEnabled }.mapNotNull { it.codebaseName } - RemoteRepoUtils.resolveReposWithErrorNotification( - project, enabledRepos.map { CodebaseName(it) }) { repos -> - chatSession.sendWebviewMessage( - WebviewMessage(command = "context/choose-remote-search-repo", explicitRepos = repos)) - } } @RequiresEdt - private fun updateTree(enabledRepos: List) { + private fun updateTree(repos: List) { // TODO: When Kotlin @RequiresEdt annotations are instrumented, remove this manual assertion. ApplicationManager.getApplication().assertIsDispatchThread() - val remotesPath = treeModel.getTreePath(remotesNode.userObject) + val remotesPath = treeModel.getTreePath(contextRoot.userObject) val wasExpanded = remotesPath != null && tree.isExpanded(remotesPath) - val remoteNodes = remotesNode.children().toList().filterIsInstance() - - remoteNodes.forEach { node -> - node.repo.isEnabled = enabledRepos.find { it.name == node.repo.name } != null - } - - enabledRepos.forEach { repo -> - val remoteNode = remoteNodes.find { it.repo.name == repo.name } - if (remoteNode == null) { - remotesNode.add( - ContextTreeRemoteRepoNode(repo) { checked -> - setRepoEnabledInContextState(repo.name, checked) - }) - } - } - - contextRoot.numRepos = enabledRepos.count { it.isIgnored != true } - contextRoot.numIgnoredRepos = enabledRepos.count { it.isIgnored == true } - treeModel.reload(contextRoot) - if (wasExpanded) { - tree.expandPath(remotesPath) - } - } - - // Given a textual list of repos, extract a best effort list of repositories from it and update - // context settings. - private fun applyRepoSpec(spec: String) { - val repos = - spec - .split(Regex("""\s+""")) - .filter { it -> it != "" } - .toSet() - .take(MAX_REMOTE_REPOSITORY_COUNT) - RemoteRepoUtils.resolveReposWithErrorNotification( - project, repos.map { it -> CodebaseName(it) }.toList()) { trimmedRepos -> - runInEdt { - // Update the plugin's copy of the state. - updateContextState { state -> - state.remoteRepositories.clear() - state.remoteRepositories.addAll( - trimmedRepos.map { repo -> - RemoteRepositoryState().apply { - codebaseName = repo.name - isEnabled = true - } - }) + contextRoot.removeAllChildren() + repos + .map { repo -> + ContextTreeRemoteRepoNode(repo) { + ApplicationManager.getApplication().executeOnPooledThread { + controller.setRepoEnabledInContextState(repo.name, !repo.isEnabled) } - - // Update the Agent state. This triggers the tree view update. - chatSession.sendWebviewMessage( - WebviewMessage( - command = "context/choose-remote-search-repo", explicitRepos = trimmedRepos)) } } - } + .forEach { contextRoot.add(it) } - private fun setRepoEnabledInContextState(repoName: String, enabled: Boolean) { - var enabledRepos = listOf() + // Add the node to add/edit the repository list. + editReposNode.hasRemovableRepos = repos.count { it.inclusion == RepoInclusion.MANUAL } > 0 + contextRoot.add(editReposNode) - updateContextState { contextState -> - contextState.remoteRepositories.find { it.codebaseName == repoName }?.isEnabled = enabled - enabledRepos = - contextState.remoteRepositories - .filter { it.isEnabled } - .mapNotNull { it.codebaseName } - .map { CodebaseName(it) } + contextRoot.numActiveRepos = repos.count { it.isEnabled } + treeModel.reload(contextRoot) + if (wasExpanded) { + tree.expandPath(remotesPath) } - RemoteRepoUtils.getRepositories(project, enabledRepos) - .completeOnTimeout(null, 15, TimeUnit.SECONDS) - .thenApply { repos -> - if (repos == null) { - runInEdt { RemoteRepoResolutionFailedNotification().notify(project) } - return@thenApply - } - chatSession.sendWebviewMessage( - WebviewMessage(command = "context/choose-remote-search-repo", explicitRepos = repos)) - } + resize() } } @@ -415,18 +403,26 @@ class ConsumerEnhancedContextPanel(project: Project, chatSession: ChatSession) : enhancedContextNode.add(localContextNode) val contextState = getContextState() - ApplicationManager.getApplication().invokeLater { - enhancedContextNode.isChecked = contextState?.isEnabled ?: true - } + updateFromSavedState(contextState ?: EnhancedContextState()) treeModel.reload() resize() } + private var toolbar: ToolbarDecorator? = null + @RequiresEdt override fun createPanel(): JComponent { - toolbar.addExtraAction(ReindexButton(project)) - toolbar.addExtraAction(HelpButton()) + val toolbar = + createDecorator(tree) + .disableUpDownActions() + .setToolbarPosition(ActionToolbarPosition.RIGHT) + .setVisibleRowCount(1) + .setScrollPaneBorder(BorderFactory.createEmptyBorder()) + .setToolbarBorder(BorderFactory.createEmptyBorder()) + .addExtraAction(ReindexButton(project)) + .addExtraAction(HelpButton()) + this.toolbar = toolbar return toolbar.createPanel() } @@ -437,10 +433,33 @@ class ConsumerEnhancedContextPanel(project: Project, chatSession: ChatSession) : /* checkParentWithCheckedChild = */ true, /* uncheckParentWithUncheckedChild = */ false) + override fun resize() { + val padding = 5 + // Set the minimum size to accommodate at least one toolbar button and an overflow ellipsis. + // Because the buttons + // are approximately square, use the toolbar width as a proxy for the button height. + val toolbarButtonHeight = toolbar?.actionsPanel?.preferredSize?.width ?: 0 + val preferredSizeNumVisibleButtons = 1 + panel.preferredSize = + Dimension( + 0, + padding + + max( + tree.rowCount * tree.rowHeight, + preferredSizeNumVisibleButtons * toolbarButtonHeight)) + panel.parent?.revalidate() + } + override fun updateFromAgent(enhancedContextStatus: EnhancedContextContextT) { // No-op. The consumer panel relies solely on JetBrains-side state. } + override fun updateFromSavedState(state: EnhancedContextState) { + ApplicationManager.getApplication().invokeLater { + enhancedContextNode.isChecked = state.isEnabled ?: true + } + } + init { prepareTree() } diff --git a/src/main/kotlin/com/sourcegraph/cody/context/ui/RemoteRepoPopupController.kt b/src/main/kotlin/com/sourcegraph/cody/context/ui/RemoteRepoPopupController.kt index 1065983dca..0175f2883b 100644 --- a/src/main/kotlin/com/sourcegraph/cody/context/ui/RemoteRepoPopupController.kt +++ b/src/main/kotlin/com/sourcegraph/cody/context/ui/RemoteRepoPopupController.kt @@ -41,7 +41,7 @@ class RemoteRepoPopupController(val project: Project) { var onAccept: (spec: String) -> Unit = {} @RequiresEdt - fun createPopup(width: Int, initialValue: String = ""): JBPopup { + fun createPopup(width: Int, endpoint: String, initialValue: String = ""): JBPopup { val psiFile = PsiFileFactory.getInstance(project) .createFileFromText( @@ -111,7 +111,7 @@ class RemoteRepoPopupController(val project: Project) { .apply { setAdText( CodyBundle.getString("context-panel.remote-repo.select-repo-advertisement") - .fmt(MAX_REMOTE_REPOSITORY_COUNT.toString(), shortcut)) + .fmt(endpoint)) setCancelOnClickOutside(true) // Do dismiss if the user clicks outside the popup. setCancelOnWindowDeactivation(false) // Don't dismiss on alt-tab away and back. setKeyEventHandler { event -> @@ -162,6 +162,10 @@ class RemoteRepoPopupController(val project: Project) { } okAction.registerCustomShortcutSet(CommonShortcuts.CTRL_ENTER, popup.content) + // If not explicitly set, the popup's minimum size is applied after the popup is shown, which is + // too late to compute placement in showAbove. + popup.size = Dimension(width, scaledHeight) + return popup } } diff --git a/src/main/kotlin/com/sourcegraph/cody/history/ChatHistoryPanel.kt b/src/main/kotlin/com/sourcegraph/cody/history/ChatHistoryPanel.kt index 9a608a9123..405e114032 100644 --- a/src/main/kotlin/com/sourcegraph/cody/history/ChatHistoryPanel.kt +++ b/src/main/kotlin/com/sourcegraph/cody/history/ChatHistoryPanel.kt @@ -267,7 +267,6 @@ class ChatHistoryPanel( private fun hasLeafSelected() = tree.selectionPath?.lastPathComponent is LeafNode private companion object { - private const val ENTER_MAP_KEY = "enter" private const val DELETE_MAP_KEY = "delete" } diff --git a/src/main/resources/CodyBundle.properties b/src/main/resources/CodyBundle.properties index 7bf837b165..9606ddd2bd 100644 --- a/src/main/resources/CodyBundle.properties +++ b/src/main/resources/CodyBundle.properties @@ -89,18 +89,23 @@ context-panel.remote-repo.error-resolution-failed.ignore=cody.ignore.notificatio context-panel.remote-repo.error-resolution-failed.title=Repository resolution failed context-panel.remote-repo.error-too-many-repositories=Add up to {0} repositories context-panel.remote-repo.error-too-many-repositories.tooltip=Too many repositories -context-panel.remote-repo.select-repo-advertisement=Select up to {0} repositories, use {1} to finish +context-panel.remote-repo.select-repo-advertisement=Type to add repos from {0} context-panel.tree.node-auto.tooltip=Included automatically based on your project context-panel.tree.node.checkbox.remove-tooltip=Uncheck to remove from enhanced context -context-panel.tree.node-chat-context.detail-ignored-repos=\ — {0} excluded context-panel.tree.node-chat-context=Chat Context -context-panel.tree.node-chat-context.detailed=Chat Context {1} {2}{3} on {4} -context-panel.tree.node-ignored.tooltip=Repository ignored by an admin setting +context-panel.tree.node-chat-context.detailed=Enhanced Context {1} {2} +context-panel.tree.node-edit-repos.label-edit=Add or remove repositories +context-panel.tree.node-edit-repos.label-add=Add repositories +context-panel.tree.node-ignored.tooltip=Repository restricted by an admin setting +context-panel.tree.node-remote-repo.label={1} {2} +context-panel.tree.node-remote-repo.auto=(Project repository) +context-panel.tree.node-remote-repo.ignored=(Restricted by admin) +context-panel.tree.node-remote-repo.auto-ignored=(Project repository, restricted by admin) +context-panel.tree.node-remote-repo.not-found=(Not found) context-panel.tree.node-local-project=Local Project -context-panel.tree.node-remote-repositories=Repositories context-panel.tree.root=repositories-root context-panel.tree.help-tooltip.title=About chat context -context-panel.tree.help-tooltip.description=Select up to {0} repositories from {1} to use as reference context in this chat. +context-panel.tree.help-tooltip.description=Select up to {0} repositories to use as reference context in this chat. context-panel.tree.help-tooltip.link.text=Context documentation context-panel.tree.help-tooltip.link.href=https://sourcegraph.com/docs/cody/core-concepts/context messages-panel.welcome-text=Hello! I'm Cody. I can write code and answer questions for you. See [Cody documentation](https://sourcegraph.com/docs/cody) for help and tips. @@ -204,3 +209,4 @@ filter.sidebar-panel-ignored-file.learn-more-cta=Learn more # Other Actions action.cody.restartAgent.text=Restart Cody Agent +chat.enhanced_context.title=Chat Context Settings diff --git a/src/main/resources/icons/actions/huge_plus.svg b/src/main/resources/icons/actions/huge_plus.svg new file mode 100644 index 0000000000..3c9286fbed --- /dev/null +++ b/src/main/resources/icons/actions/huge_plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/actions/huge_plus_dark.svg b/src/main/resources/icons/actions/huge_plus_dark.svg new file mode 100644 index 0000000000..f23bef2b33 --- /dev/null +++ b/src/main/resources/icons/actions/huge_plus_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/actions/pencil.svg b/src/main/resources/icons/actions/pencil.svg new file mode 100644 index 0000000000..458473f08c --- /dev/null +++ b/src/main/resources/icons/actions/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/actions/pencil_dark.svg b/src/main/resources/icons/actions/pencil_dark.svg new file mode 100644 index 0000000000..c9c25914c6 --- /dev/null +++ b/src/main/resources/icons/actions/pencil_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/repo-host-bitbucket.svg b/src/main/resources/icons/repo-host-bitbucket.svg index 7f29e847e6..6b717a46d6 100644 --- a/src/main/resources/icons/repo-host-bitbucket.svg +++ b/src/main/resources/icons/repo-host-bitbucket.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/repo-host-bitbucket_dark.svg b/src/main/resources/icons/repo-host-bitbucket_dark.svg new file mode 100644 index 0000000000..b4f7befbac --- /dev/null +++ b/src/main/resources/icons/repo-host-bitbucket_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/repo-host-generic.svg b/src/main/resources/icons/repo-host-generic.svg index e051502da9..a83977a042 100644 --- a/src/main/resources/icons/repo-host-generic.svg +++ b/src/main/resources/icons/repo-host-generic.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/repo-host-generic_dark.svg b/src/main/resources/icons/repo-host-generic_dark.svg new file mode 100644 index 0000000000..5ab1c0885a --- /dev/null +++ b/src/main/resources/icons/repo-host-generic_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/repo-host-github.svg b/src/main/resources/icons/repo-host-github.svg index cb0c6480ba..fae818bb9a 100644 --- a/src/main/resources/icons/repo-host-github.svg +++ b/src/main/resources/icons/repo-host-github.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/repo-host-github_dark.svg b/src/main/resources/icons/repo-host-github_dark.svg new file mode 100644 index 0000000000..8d37fcb8bb --- /dev/null +++ b/src/main/resources/icons/repo-host-github_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/repo-host-gitlab.svg b/src/main/resources/icons/repo-host-gitlab.svg index ee96c722d3..52451f97e0 100644 --- a/src/main/resources/icons/repo-host-gitlab.svg +++ b/src/main/resources/icons/repo-host-gitlab.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/repo-host-gitlab_dark.svg b/src/main/resources/icons/repo-host-gitlab_dark.svg new file mode 100644 index 0000000000..17a715224d --- /dev/null +++ b/src/main/resources/icons/repo-host-gitlab_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/repo-ignored.svg b/src/main/resources/icons/repo-ignored.svg index cf56d8e19e..53ffc9e31a 100644 --- a/src/main/resources/icons/repo-ignored.svg +++ b/src/main/resources/icons/repo-ignored.svg @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/src/main/resources/icons/repo-ignored_dark.svg b/src/main/resources/icons/repo-ignored_dark.svg new file mode 100644 index 0000000000..b97ffdf0e3 --- /dev/null +++ b/src/main/resources/icons/repo-ignored_dark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file