diff --git a/src/main/kotlin/com/sourcegraph/cody/autocomplete/CodyAutocompleteManager.kt b/src/main/kotlin/com/sourcegraph/cody/autocomplete/CodyAutocompleteManager.kt index c861d067e5..8216c8c3b5 100644 --- a/src/main/kotlin/com/sourcegraph/cody/autocomplete/CodyAutocompleteManager.kt +++ b/src/main/kotlin/com/sourcegraph/cody/autocomplete/CodyAutocompleteManager.kt @@ -2,6 +2,7 @@ package com.sourcegraph.cody.autocomplete import com.intellij.codeInsight.hint.HintManager import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.command.CommandProcessor import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.components.Service @@ -34,6 +35,9 @@ import com.sourcegraph.cody.autocomplete.render.CodyAutocompleteElementRenderer import com.sourcegraph.cody.autocomplete.render.CodyAutocompleteSingleLineRenderer import com.sourcegraph.cody.autocomplete.render.InlayModelUtil.getAllInlaysForEditor import com.sourcegraph.cody.config.CodyAuthenticationManager +import com.sourcegraph.cody.ignore.ActionInIgnoredFileNotification +import com.sourcegraph.cody.ignore.IgnoreOracle +import com.sourcegraph.cody.ignore.IgnorePolicy import com.sourcegraph.cody.statusbar.CodyStatus import com.sourcegraph.cody.statusbar.CodyStatusService.Companion.notifyApplication import com.sourcegraph.cody.statusbar.CodyStatusService.Companion.resetApplication @@ -234,42 +238,52 @@ class CodyAutocompleteManager { val resultOuter = CompletableFuture() CodyAgentService.withAgent(project) { agent -> - val completions = agent.server.autocompleteExecute(params) - - // Important: we have to `.cancel()` the original `CompletableFuture` from lsp4j. As soon - // as we use `thenAccept()` we get a new instance of `CompletableFuture` which does not - // correctly propagate the cancellation to the agent. - cancellationToken.onCancellationRequested { completions.cancel(true) } - - ApplicationManager.getApplication().executeOnPooledThread { - completions - .handle { result, error -> - if (error != null) { - if (triggerKind == InlineCompletionTriggerKind.INVOKE || - !UpgradeToCodyProNotification.isFirstRLEOnAutomaticAutocompletionsShown) { - handleError(project, error) + if (triggerKind == InlineCompletionTriggerKind.INVOKE && + IgnoreOracle.getInstance(project).policyForUri(virtualFile.url, agent).get() != + IgnorePolicy.USE) { + runInEdt { ActionInIgnoredFileNotification().notify(project) } + resetApplication(project) + resultOuter.cancel(true) + null + } else { + val completions = agent.server.autocompleteExecute(params) + + // Important: we have to `.cancel()` the original `CompletableFuture` from lsp4j. As soon + // as we use `thenAccept()` we get a new instance of `CompletableFuture` which does + // not + // correctly propagate the cancellation to the agent. + cancellationToken.onCancellationRequested { completions.cancel(true) } + + ApplicationManager.getApplication().executeOnPooledThread { + completions + .handle { result, error -> + if (error != null) { + if (triggerKind == InlineCompletionTriggerKind.INVOKE || + !UpgradeToCodyProNotification.isFirstRLEOnAutomaticAutocompletionsShown) { + handleError(project, error) + } + } else if (result != null && result.items.isNotEmpty()) { + UpgradeToCodyProNotification.isFirstRLEOnAutomaticAutocompletionsShown = false + UpgradeToCodyProNotification.autocompleteRateLimitError.set(null) + CodyToolWindowContent.executeOnInstanceIfNotDisposed(project) { + refreshMyAccountTab() + } + processAutocompleteResult(editor, offset, triggerKind, result, cancellationToken) } - } else if (result != null && result.items.isNotEmpty()) { - UpgradeToCodyProNotification.isFirstRLEOnAutomaticAutocompletionsShown = false - UpgradeToCodyProNotification.autocompleteRateLimitError.set(null) - CodyToolWindowContent.executeOnInstanceIfNotDisposed(project) { - refreshMyAccountTab() + null + } + .exceptionally { error: Throwable? -> + if (!(error is CancellationException || error is CompletionException)) { + logger.warn("failed autocomplete request $params", error) } - processAutocompleteResult(editor, offset, triggerKind, result, cancellationToken) + null } - null - } - .exceptionally { error: Throwable? -> - if (!(error is CancellationException || error is CompletionException)) { - logger.warn("failed autocomplete request $params", error) + .completeOnTimeout(null, 3, TimeUnit.SECONDS) + .thenRun { // This is a terminal operation, so we needn't call get(). + resetApplication(project) + resultOuter.complete(null) } - null - } - .completeOnTimeout(null, 3, TimeUnit.SECONDS) - .thenRun { // This is a terminal operation, so we needn't call get(). - resetApplication(project) - resultOuter.complete(null) - } + } } } cancellationToken.onCancellationRequested { resultOuter.cancel(true) } diff --git a/src/main/kotlin/com/sourcegraph/cody/chat/actions/BaseCommandAction.kt b/src/main/kotlin/com/sourcegraph/cody/chat/actions/BaseCommandAction.kt index 2bda775fe4..0de6651763 100644 --- a/src/main/kotlin/com/sourcegraph/cody/chat/actions/BaseCommandAction.kt +++ b/src/main/kotlin/com/sourcegraph/cody/chat/actions/BaseCommandAction.kt @@ -11,7 +11,7 @@ import com.sourcegraph.cody.CodyToolWindowContent import com.sourcegraph.cody.agent.protocol.ProtocolTextDocument import com.sourcegraph.cody.chat.AgentChatSession import com.sourcegraph.cody.commands.CommandId -import com.sourcegraph.cody.context.ui.ActionInIgnoredFileNotification +import com.sourcegraph.cody.ignore.ActionInIgnoredFileNotification import com.sourcegraph.cody.ignore.IgnoreOracle import com.sourcegraph.cody.ignore.IgnorePolicy import java.util.concurrent.Callable diff --git a/src/main/kotlin/com/sourcegraph/cody/commands/ui/CommandsTabPanel.kt b/src/main/kotlin/com/sourcegraph/cody/commands/ui/CommandsTabPanel.kt index 9e995c4abf..3402a3c345 100644 --- a/src/main/kotlin/com/sourcegraph/cody/commands/ui/CommandsTabPanel.kt +++ b/src/main/kotlin/com/sourcegraph/cody/commands/ui/CommandsTabPanel.kt @@ -6,27 +6,37 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Presentation import com.intellij.openapi.actionSystem.ex.ActionManagerEx import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.ui.components.JBPanelWithEmptyText import com.sourcegraph.cody.commands.CommandId import com.sourcegraph.cody.config.CodyApplicationSettings +import com.sourcegraph.cody.ignore.CommandPanelIgnoreBanner +import com.sourcegraph.cody.ignore.IgnoreOracle +import com.sourcegraph.cody.ignore.IgnorePolicy import com.sourcegraph.config.ConfigUtil import java.awt.Component import java.awt.Dimension import java.awt.GridLayout import javax.swing.BoxLayout import javax.swing.JButton +import javax.swing.JComponent import javax.swing.plaf.ButtonUI class CommandsTabPanel( private val project: Project, private val executeCommand: (CommandId) -> Unit -) : JBPanelWithEmptyText(GridLayout(/* rows = */ 0, /* cols = */ 1)) { +) : + JBPanelWithEmptyText(GridLayout(/* rows = */ 0, /* cols = */ 1)), + IgnoreOracle.FocusedFileIgnorePolicyListener { + private val ignoreBanner = CommandPanelIgnoreBanner() + private val buttons = mutableListOf() init { layout = BoxLayout(this, BoxLayout.Y_AXIS) + CommandId.values().forEach { command -> addCommandButton(command) } if (ConfigUtil.isFeatureFlagEnabled("cody.feature.inline-edits") || @@ -35,6 +45,16 @@ class CommandsTabPanel( addInlineEditActionButton("cody.documentCodeAction") addInlineEditActionButton("cody.testCodeAction") } + + addHierarchyListener { + if (!project.isDisposed) { + if (it.component.isShowing) { + IgnoreOracle.getInstance(project).addListener(this) + } else { + IgnoreOracle.getInstance(project).removeListener(this) + } + } + } } private fun addInlineEditActionButton(actionId: String) { @@ -63,5 +83,28 @@ class CommandsTabPanel( button.setUI(buttonUI) button.addActionListener { action() } add(button) + + buttons.add(button) + } + + private fun update(policy: IgnorePolicy) { + // Dis/enable all the buttons. + for (button in buttons) { + button.isEnabled = policy == IgnorePolicy.USE + } + when (policy) { + IgnorePolicy.USE -> { + remove(ignoreBanner) + } + IgnorePolicy.IGNORE -> { + add(ignoreBanner, 0) + } + } + revalidate() + repaint() + } + + override fun focusedFileIgnorePolicyChanged(policy: IgnorePolicy) { + runInEdt { update(policy) } } } diff --git a/src/main/kotlin/com/sourcegraph/cody/ignore/ActionInIgnoredFileNotification.kt b/src/main/kotlin/com/sourcegraph/cody/ignore/ActionInIgnoredFileNotification.kt index 4f4ddee0e2..9c56a7e1e9 100644 --- a/src/main/kotlin/com/sourcegraph/cody/ignore/ActionInIgnoredFileNotification.kt +++ b/src/main/kotlin/com/sourcegraph/cody/ignore/ActionInIgnoredFileNotification.kt @@ -1,4 +1,4 @@ -package com.sourcegraph.cody.context.ui +package com.sourcegraph.cody.ignore import com.intellij.ide.BrowserUtil import com.intellij.notification.Notification @@ -7,14 +7,15 @@ import com.intellij.notification.NotificationType import com.intellij.notification.impl.NotificationFullContent import com.intellij.openapi.actionSystem.AnActionEvent import com.sourcegraph.Icons -import com.sourcegraph.cody.ignore.CODY_IGNORE_DOCS_URL import com.sourcegraph.common.CodyBundle import com.sourcegraph.common.NotificationGroups +const val CODY_IGNORE_DOCS_URL = "https://sourcegraph.com/docs/cody/capabilities/ignore-context" + class ActionInIgnoredFileNotification : Notification( NotificationGroups.SOURCEGRAPH_ERRORS, - "", + CodyBundle.getString("ignore.action-in-ignored-file.title"), CodyBundle.getString("ignore.action-in-ignored-file.detail"), NotificationType.INFORMATION), NotificationFullContent { diff --git a/src/main/kotlin/com/sourcegraph/cody/ignore/CommandPanelIgnoreBanner.kt b/src/main/kotlin/com/sourcegraph/cody/ignore/CommandPanelIgnoreBanner.kt new file mode 100644 index 0000000000..41a8e2b7f4 --- /dev/null +++ b/src/main/kotlin/com/sourcegraph/cody/ignore/CommandPanelIgnoreBanner.kt @@ -0,0 +1,41 @@ +package com.sourcegraph.cody.ignore + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.SideBorder +import com.intellij.ui.components.panels.NonOpaquePanel +import com.sourcegraph.Icons +import com.sourcegraph.common.CodyBundle +import java.awt.Dimension + +class CommandPanelIgnoreBanner() : NonOpaquePanel() { + init { + ApplicationManager.getApplication().assertIsDispatchThread() + + add( + EditorNotificationPanel().apply { + text = CodyBundle.getString("ignore.sidebar-panel-ignored-file.text") + createActionLabel( + CodyBundle.getString("ignore.sidebar-panel-ignored-file.learn-more-cta"), + { BrowserUtil.browse(CODY_IGNORE_DOCS_URL) }, + false) + icon(Icons.CodyLogoSlash) + }) + + // These colors cribbed from EditorComposite, createTopBottomSideBorder + val scheme = EditorColorsManager.getInstance().globalScheme + val borderColor = + scheme.getColor(EditorColors.SEPARATOR_ABOVE_COLOR) + ?: scheme.getColor(EditorColors.TEARLINE_COLOR) + border = SideBorder(borderColor, SideBorder.TOP or SideBorder.BOTTOM) + } + + override fun getMaximumSize(): Dimension { + val size = super.getMaximumSize() + size.height = preferredSize.height + return size + } +} diff --git a/src/main/kotlin/com/sourcegraph/cody/ignore/IgnoreNotificationProvider.kt b/src/main/kotlin/com/sourcegraph/cody/ignore/IgnoreNotificationProvider.kt deleted file mode 100644 index a1d1d1270f..0000000000 --- a/src/main/kotlin/com/sourcegraph/cody/ignore/IgnoreNotificationProvider.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.sourcegraph.cody.ignore - -import com.intellij.ide.BrowserUtil -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.fileEditor.FileEditor -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.ui.EditorNotificationPanel -import com.intellij.ui.EditorNotificationProvider -import com.intellij.ui.EditorNotifications -import com.intellij.vcs.log.runInEdt -import com.sourcegraph.Icons -import com.sourcegraph.cody.agent.protocol.ProtocolTextDocument -import java.util.function.Function -import javax.swing.JComponent - -const val CODY_IGNORE_DOCS_URL = "https://sourcegraph.com/docs/cody/capabilities/ignore-context" - -class IgnoreNotificationProvider : EditorNotificationProvider, DumbAware { - override fun collectNotificationData( - project: Project, - file: VirtualFile - ): Function { - val uri = ProtocolTextDocument.uriFor(file) - val oracle = IgnoreOracle.getInstance(project) - val policy = oracle.policyForUriOrElse(uri) { runInEdt { updateNotifications(project) } } - if (policy == IgnorePolicy.USE || policy == null) { - // This file is allowed, or the policy is indeterminate. - return Function { null } - } - return Function { - EditorNotificationPanel(it).apply { - icon(Icons.CodyLogoSlash) - // TODO: This message is specific to the enterprise product and needs to be changed when we - // support cody ignore in the self-serve product - text = "Cody ignores this file because of your admin policy" - - createActionLabel( - "Learn more", Runnable { BrowserUtil.browse(CODY_IGNORE_DOCS_URL) }, false) - } - } - } - - companion object { - /** Update editor notifications to refresh banners. */ - fun updateNotifications(project: Project) { - ApplicationManager.getApplication().assertReadAccessAllowed() - if (!project.isDisposed) { - EditorNotifications.getInstance(project).updateAllNotifications() - } - } - } -} diff --git a/src/main/kotlin/com/sourcegraph/cody/ignore/IgnoreOracle.kt b/src/main/kotlin/com/sourcegraph/cody/ignore/IgnoreOracle.kt index 3a81c81fc0..63a2db00ff 100644 --- a/src/main/kotlin/com/sourcegraph/cody/ignore/IgnoreOracle.kt +++ b/src/main/kotlin/com/sourcegraph/cody/ignore/IgnoreOracle.kt @@ -1,12 +1,16 @@ package com.sourcegraph.cody.ignore import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.components.Service import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.util.containers.SLRUMap +import com.sourcegraph.cody.agent.CodyAgent import com.sourcegraph.cody.agent.CodyAgentService import com.sourcegraph.cody.agent.protocol.IgnoreTestParams +import com.sourcegraph.cody.agent.protocol.ProtocolTextDocument import com.sourcegraph.cody.statusbar.CodyStatusService import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @@ -32,6 +36,21 @@ class IgnoreOracle(private val project: Project) { private val cache = SLRUMap(100, 100) @Volatile private var focusedPolicy: IgnorePolicy? = null @Volatile private var willFocusUri: String? = null + private val fileListeners: MutableList = mutableListOf() + + init { + // Synthesize a focus event for the current editor, if any, to fetch and cache ignore state for + // it. + runInEdt { + val editor = FileEditorManager.getInstance(project).selectedTextEditor + if (willFocusUri == null && editor != null) { + val uri = ProtocolTextDocument.fromEditor(editor)?.uri + if (uri != null) { + focusedFileDidChange(uri) + } + } + } + } val isEditingIgnoredFile: Boolean get() { @@ -44,11 +63,28 @@ class IgnoreOracle(private val project: Project) { val policy = policyForUri(uri).get() if (focusedPolicy != policy && willFocusUri == uri) { focusedPolicy = policy + + // Update the status bar. CodyStatusService.resetApplication(project) + + val listeners = synchronized(fileListeners) { fileListeners.toList() } + for (listener in listeners) { + listener.focusedFileIgnorePolicyChanged(policy) + } } } } + fun addListener(listener: FocusedFileIgnorePolicyListener) { + synchronized(fileListeners) { fileListeners.add(listener) } + // Invoke the listener with the focused file policy to set initial state. + listener.focusedFileIgnorePolicyChanged(focusedPolicy ?: IgnorePolicy.USE) + } + + fun removeListener(listener: FocusedFileIgnorePolicyListener) { + synchronized(fileListeners) { fileListeners.remove(listener) } + } + /** * Notifies the IgnoreOracle that the ignore policy has changed. Called by CodyAgentService's * client callbacks. @@ -56,8 +92,6 @@ class IgnoreOracle(private val project: Project) { fun onIgnoreDidChange() { synchronized(cache) { cache.clear() } - IgnoreNotificationProvider.updateNotifications(project) - // Re-set the focused file URI to update the status bar. val uri = willFocusUri if (uri != null) { @@ -74,16 +108,23 @@ class IgnoreOracle(private val project: Project) { return completable } CodyAgentService.withAgent(project) { agent -> + policyForUri(uri, agent).thenAccept(completable::complete) + } + return completable + } + + /** Like `policyForUri(String)` but reuses the current thread and supplied Agent handle. */ + fun policyForUri(uri: String, agent: CodyAgent): CompletableFuture { + return agent.server.ignoreTest(IgnoreTestParams(uri)).thenApply { val policy = - when (agent.server.ignoreTest(IgnoreTestParams(uri)).get().policy) { + when (it.policy) { "ignore" -> IgnorePolicy.IGNORE "use" -> IgnorePolicy.USE else -> throw IllegalStateException("invalid ignore policy value") } synchronized(cache) { cache.put(uri, policy) } - completable.complete(policy) + policy } - return completable } /** @@ -99,4 +140,8 @@ class IgnoreOracle(private val project: Project) { return null } } + + interface FocusedFileIgnorePolicyListener { + fun focusedFileIgnorePolicyChanged(policy: IgnorePolicy) + } } diff --git a/src/main/kotlin/com/sourcegraph/cody/statusbar/CodyStatus.kt b/src/main/kotlin/com/sourcegraph/cody/statusbar/CodyStatus.kt index 31ce4a1f88..db7a18d8e3 100644 --- a/src/main/kotlin/com/sourcegraph/cody/statusbar/CodyStatus.kt +++ b/src/main/kotlin/com/sourcegraph/cody/statusbar/CodyStatus.kt @@ -3,6 +3,7 @@ package com.sourcegraph.cody.statusbar import com.intellij.util.ui.PresentableEnum import com.sourcegraph.cody.Icons import com.sourcegraph.cody.agent.CodyAgentService +import com.sourcegraph.common.CodyBundle import javax.swing.Icon interface WithIcon { @@ -27,7 +28,7 @@ enum class CodyStatus : PresentableEnum, WithIcon { }, InIgnoredFile { override fun getPresentableText(): String = - "Cody autocomplete is disabled in this file because of your admin policy" + CodyBundle.getString("ignore.status-bar-ignored-file.tooltip") override val icon: Icon = Icons.StatusBar.CodyAutocompleteDisabled }, diff --git a/src/main/resources/CodyBundle.properties b/src/main/resources/CodyBundle.properties index 3bb56489a9..82a3c76e0b 100644 --- a/src/main/resources/CodyBundle.properties +++ b/src/main/resources/CodyBundle.properties @@ -183,5 +183,9 @@ gotit.autocomplete.message=This is how Cody displays autocomplete suggestions.This file has been marked as ignored by an admin, which means commands that rely on its contents cannot be executed. +ignore.sidebar-panel-ignored-file.learn-more-cta=Learn more diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index bfae3a1d61..c99967259c 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -110,9 +110,6 @@ order="first, before commitCompletion"/> - - -