diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/AuthStatus.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/AuthStatus.kt index f4554f7ee4e..02f5a145f0e 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/AuthStatus.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/protocol_generated/AuthStatus.kt @@ -3,7 +3,7 @@ package com.sourcegraph.cody.protocol_generated; data class AuthStatus( val username: String, - val endpoint: String? = null, + val endpoint: String, val isDotCom: Boolean, val isLoggedIn: Boolean, val isFireworksTracingEnabled: Boolean, diff --git a/agent/src/cli/command-bench/command-bench.ts b/agent/src/cli/command-bench/command-bench.ts index 65a8cedd7b8..ffcdf3f3531 100644 --- a/agent/src/cli/command-bench/command-bench.ts +++ b/agent/src/cli/command-bench/command-bench.ts @@ -314,7 +314,7 @@ export const benchCommand = new commander.Command('bench') ) // Required to use `PromptString`. - graphqlClient.onConfigurationChange({ + graphqlClient.setConfig({ accessToken: options.srcAccessToken, serverEndpoint: options.srcEndpoint, customHeaders: {}, diff --git a/lib/shared/src/auth/types.ts b/lib/shared/src/auth/types.ts index a97c36c9798..720dd963bcf 100644 --- a/lib/shared/src/auth/types.ts +++ b/lib/shared/src/auth/types.ts @@ -6,7 +6,7 @@ import type { CodyLLMSiteConfiguration } from '../sourcegraph-api/graphql/client */ export interface AuthStatus { username: string - endpoint: string | null + endpoint: string isDotCom: boolean isLoggedIn: boolean /** diff --git a/lib/shared/src/experimentation/FeatureFlagProvider.test.ts b/lib/shared/src/experimentation/FeatureFlagProvider.test.ts index 8216d763777..47bcf677e3e 100644 --- a/lib/shared/src/experimentation/FeatureFlagProvider.test.ts +++ b/lib/shared/src/experimentation/FeatureFlagProvider.test.ts @@ -26,7 +26,7 @@ describe('FeatureFlagProvider', () => { } const provider = new FeatureFlagProvider(apiClient as unknown as SourcegraphGraphQLAPIClient) - await provider.syncAuthStatus() + await provider.refresh() // Wait for the async initialization await nextTick() @@ -64,7 +64,7 @@ describe('FeatureFlagProvider', () => { [FeatureFlag.TestFlagDoNotUse]: false, }) - await provider.syncAuthStatus() + await provider.refresh() // Wait for the async reload await nextTick() @@ -84,7 +84,7 @@ describe('FeatureFlagProvider', () => { } const provider = new FeatureFlagProvider(apiClient as unknown as SourcegraphGraphQLAPIClient) - await provider.syncAuthStatus() + await provider.refresh() // Wait for the async initialization await nextTick() diff --git a/lib/shared/src/experimentation/FeatureFlagProvider.ts b/lib/shared/src/experimentation/FeatureFlagProvider.ts index fdad9dcf7b8..1bdfa4f179b 100644 --- a/lib/shared/src/experimentation/FeatureFlagProvider.ts +++ b/lib/shared/src/experimentation/FeatureFlagProvider.ts @@ -161,13 +161,13 @@ export class FeatureFlagProvider { }) } - public async syncAuthStatus(): Promise { + public async refresh(): Promise { this.exposedFeatureFlags = {} this.unexposedFeatureFlags = {} await this.refreshFeatureFlags() } - public async refreshFeatureFlags(): Promise { + private async refreshFeatureFlags(): Promise { return wrapInActiveSpan('FeatureFlagProvider.refreshFeatureFlags', async () => { const endpoint = this.apiClient.endpoint const data = process.env.DISABLE_FEATURE_FLAGS diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index 329b1d37f2c..cf2c1002581 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -510,7 +510,7 @@ export class SourcegraphGraphQLAPIClient { this._config = config } - public onConfigurationChange(newConfig: GraphQLAPIClientConfig): void { + public setConfig(newConfig: GraphQLAPIClientConfig): void { this._config = newConfig } @@ -1342,7 +1342,7 @@ export class ClientConfigSingleton { return ClientConfigSingleton.instance } - public async syncAuthStatus(authStatus: AuthStatus): Promise { + public async setAuthStatus(authStatus: AuthStatus): Promise { this.isSignedIn = authStatus.authenticated && authStatus.isLoggedIn if (this.isSignedIn) { await this.refreshConfig() diff --git a/vscode/src/chat/chat-view/ChatController.ts b/vscode/src/chat/chat-view/ChatController.ts index 9000ee2605e..140f9fc4d94 100644 --- a/vscode/src/chat/chat-view/ChatController.ts +++ b/vscode/src/chat/chat-view/ChatController.ts @@ -529,7 +529,7 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv // #region top-level view action handlers // ======================================================================= - public syncAuthStatus(): void { + public setAuthStatus(authStatus: AuthStatus): void { // Run this async because this method may be called during initialization // and awaiting on this.postMessage may result in a deadlock void this.sendConfig() diff --git a/vscode/src/chat/chat-view/ChatsController.ts b/vscode/src/chat/chat-view/ChatsController.ts index 72550f0f64e..106321923e5 100644 --- a/vscode/src/chat/chat-view/ChatsController.ts +++ b/vscode/src/chat/chat-view/ChatsController.ts @@ -27,6 +27,7 @@ import type { ExecuteChatArguments } from '../../commands/execute/ask' import { getConfiguration } from '../../configuration' import type { EnterpriseContextFactory } from '../../context/enterprise-context-factory' import type { ContextRankingController } from '../../local-context/context-ranking' +import type { AuthProvider } from '../../services/AuthProvider' import { ChatController, type ChatSession, @@ -63,6 +64,7 @@ export class ChatsController implements vscode.Disposable { constructor( private options: Options, private chatClient: ChatClient, + private authProvider: AuthProvider, private readonly enterpriseContext: EnterpriseContextFactory, private readonly localEmbeddings: LocalEmbeddingsController | null, private readonly contextRanking: ContextRankingController | null, @@ -71,9 +73,14 @@ export class ChatsController implements vscode.Disposable { ) { logDebug('ChatsController:constructor', 'init') this.panel = this.createChatController() + this.disposables.push( + this.authProvider.onChange(authStatus => this.setAuthStatus(authStatus), { + runImmediately: true, + }) + ) } - public async syncAuthStatus(authStatus: AuthStatus): Promise { + private async setAuthStatus(authStatus: AuthStatus): Promise { const hasLoggedOut = !authStatus.isLoggedIn const hasSwitchedAccount = this.currentAuthAccount && this.currentAuthAccount.endpoint !== authStatus.endpoint @@ -88,9 +95,8 @@ export class ChatsController implements vscode.Disposable { username: authStatus.username, } - this.supportTreeViewProvider.syncAuthStatus(authStatus) - - this.panel.syncAuthStatus() + this.supportTreeViewProvider.setAuthStatus(authStatus) + this.panel.setAuthStatus(authStatus) } public async restoreToPanel(panel: vscode.WebviewPanel, chatID: string): Promise { diff --git a/vscode/src/commands/GhostHintDecorator.ts b/vscode/src/commands/GhostHintDecorator.ts index 4a955d76228..9f6fe44357b 100644 --- a/vscode/src/commands/GhostHintDecorator.ts +++ b/vscode/src/commands/GhostHintDecorator.ts @@ -159,7 +159,12 @@ const GHOST_TEXT_THROTTLE = 250 const TELEMETRY_THROTTLE = 30 * 1000 // 30 Seconds export class GhostHintDecorator implements vscode.Disposable { - private disposables: vscode.Disposable[] = [] + // permanentDisposables are disposed when this instance is disposed. + private permanentDisposables: vscode.Disposable[] = [] + + // activeDisposables are disposed when the ghost hint is inactive (e.g., due to sign out) + private activeDisposables: vscode.Disposable[] = [] + private isActive = false private activeDecorationRange: vscode.Range | null = null private setThrottledGhostText: DebouncedFunc @@ -196,10 +201,14 @@ export class GhostHintDecorator implements vscode.Disposable { this.updateEnablement(initialAuth) // Listen to authentication changes - authProvider.addChangeListener(authStatus => this.updateEnablement(authStatus)) + this.permanentDisposables.push( + authProvider.onChange(authStatus => this.updateEnablement(authStatus), { + runImmediately: true, + }) + ) // Listen to configuration changes (e.g. if the setting is disabled) - this.disposables.push( + this.permanentDisposables.push( vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('cody')) { this.updateEnablement(authProvider.getAuthStatus()) @@ -219,7 +228,7 @@ export class GhostHintDecorator implements vscode.Disposable { } private init(enabledFeatures: EnabledFeatures): void { - this.disposables.push( + this.activeDisposables.push( vscode.window.onDidChangeTextEditorSelection( async (event: vscode.TextEditorSelectionChangeEvent) => { const editor = event.textEditor @@ -317,7 +326,7 @@ export class GhostHintDecorator implements vscode.Disposable { ) if (enabledFeatures.Generate) { - this.disposables.push( + this.activeDisposables.push( vscode.window.onDidChangeActiveTextEditor((editor?: vscode.TextEditor) => { if (!editor) { return @@ -409,7 +418,7 @@ export class GhostHintDecorator implements vscode.Disposable { !authStatus.isLoggedIn || !(featureEnablement.Document || featureEnablement.EditOrChat || featureEnablement.Generate) ) { - this.dispose() + this.disposeActive() return } @@ -421,6 +430,14 @@ export class GhostHintDecorator implements vscode.Disposable { } public dispose(): void { + this.disposeActive() + for (const d of this.permanentDisposables) { + d.dispose() + } + this.permanentDisposables = [] + } + + private disposeActive(): void { this.isActive = false // Clear any existing ghost text @@ -428,9 +445,9 @@ export class GhostHintDecorator implements vscode.Disposable { this.clearGhostText(vscode.window.activeTextEditor) } - for (const disposable of this.disposables) { + for (const disposable of this.activeDisposables) { disposable.dispose() } - this.disposables = [] + this.activeDisposables = [] } } diff --git a/vscode/src/commands/scm/source-control.ts b/vscode/src/commands/scm/source-control.ts index 8d39099d08f..ad593d5f2c4 100644 --- a/vscode/src/commands/scm/source-control.ts +++ b/vscode/src/commands/scm/source-control.ts @@ -231,7 +231,7 @@ export class CodySourceControl implements vscode.Disposable { this.abortController = abortController } - public syncAuthStatus(authStatus: AuthStatus): void { + public setAuthStatus(authStatus: AuthStatus): void { const models = ModelsService.getModels(ModelUsage.Chat, authStatus) const preferredModel = models?.find(p => p.model.includes('claude-3-haiku')) this.model = preferredModel ?? models[0] diff --git a/vscode/src/completions/inline-completion-item-provider.test.ts b/vscode/src/completions/inline-completion-item-provider.test.ts index cc44a09f18e..38e58016d33 100644 --- a/vscode/src/completions/inline-completion-item-provider.test.ts +++ b/vscode/src/completions/inline-completion-item-provider.test.ts @@ -60,7 +60,7 @@ const DUMMY_AUTH_STATUS: AuthStatus = { codyApiVersion: 0, } -graphqlClient.onConfigurationChange({} as unknown as GraphQLAPIClientConfig) +graphqlClient.setConfig({} as unknown as GraphQLAPIClientConfig) class MockableInlineCompletionItemProvider extends InlineCompletionItemProvider { constructor( diff --git a/vscode/src/completions/providers/create-provider.test.ts b/vscode/src/completions/providers/create-provider.test.ts index 9d3ec798683..438cda83448 100644 --- a/vscode/src/completions/providers/create-provider.test.ts +++ b/vscode/src/completions/providers/create-provider.test.ts @@ -35,7 +35,7 @@ const dummyCodeCompletionsClient: CodeCompletionsClient = { const dummyAuthStatus: AuthStatus = defaultAuthStatus -graphqlClient.onConfigurationChange({} as unknown as GraphQLAPIClientConfig) +graphqlClient.setConfig({} as unknown as GraphQLAPIClientConfig) describe('createProviderConfig', () => { describe('if completions provider fields are defined in VSCode settings', () => { diff --git a/vscode/src/configwatcher.ts b/vscode/src/configwatcher.ts index dd7c5f28313..22cfdcb2184 100644 --- a/vscode/src/configwatcher.ts +++ b/vscode/src/configwatcher.ts @@ -1,4 +1,11 @@ +import type { ConfigurationWithAccessToken } from '@sourcegraph/cody-shared' import * as vscode from 'vscode' +import { getFullConfig } from './configuration' +import type { AuthProvider } from './services/AuthProvider' + +interface OnChangeOptions { + runImmediately: boolean +} /** * A wrapper around a configuration source that lets the client retrieve the current config and watch for changes. @@ -6,48 +13,50 @@ import * as vscode from 'vscode' export interface ConfigWatcher extends vscode.Disposable { get(): C - // NOTE(beyang): should remove this - set(c: C): void - /* * Register a callback that is called only when Cody's configuration is changed. - * Appends to the disposable array methods that unregister the callback - */ - onChange(callback: (config: C) => Promise, disposables: vscode.Disposable[]): void - - /** - * Same behavior as onChange, but fires the callback once immediately for initialization. + * Appends to the disposable array methods that unregister the callback. + * + * If `runImmediately` is true, the callback is called immediately and the returned + * Promise is that of the callback. If false (the default), then the return value + * is a resolved Promise. */ - initAndOnChange( + onChange( callback: (config: C) => Promise, - disposables: vscode.Disposable[] + disposables: vscode.Disposable[], + options?: OnChangeOptions ): Promise } -export class BaseConfigWatcher implements ConfigWatcher { - private currentConfig: C +export class BaseConfigWatcher implements ConfigWatcher { + private currentConfig: ConfigurationWithAccessToken private disposables: vscode.Disposable[] = [] - private configChangeEvent: vscode.EventEmitter + private configChangeEvent: vscode.EventEmitter - public static async create( - getConfig: () => Promise, + public static async create( + authProvider: AuthProvider, disposables: vscode.Disposable[] - ): Promise> { - const w = new BaseConfigWatcher(await getConfig()) + ): Promise> { + const w = new BaseConfigWatcher(await getFullConfig()) + disposables.push(w) disposables.push( vscode.workspace.onDidChangeConfiguration(async event => { if (!event.affectsConfiguration('cody')) { return } - w.set(await getConfig()) + w.set(await getFullConfig()) + }) + ) + disposables.push( + authProvider.onChange(async () => { + w.set(await getFullConfig()) }) ) - disposables.push(w) return w } - constructor(initialConfig: C) { + constructor(initialConfig: ConfigurationWithAccessToken) { this.currentConfig = initialConfig this.configChangeEvent = new vscode.EventEmitter() this.disposables.push(this.configChangeEvent) @@ -60,7 +69,22 @@ export class BaseConfigWatcher implements ConfigWatcher { this.disposables = [] } - public set(config: C): void { + public get(): ConfigurationWithAccessToken { + return this.currentConfig + } + + public async onChange( + callback: (config: ConfigurationWithAccessToken) => Promise, + disposables: vscode.Disposable[], + { runImmediately }: OnChangeOptions = { runImmediately: false } + ): Promise { + disposables.push(this.configChangeEvent.event(callback)) + if (runImmediately) { + await callback(this.currentConfig) + } + } + + private set(config: ConfigurationWithAccessToken): void { const oldConfig = JSON.stringify(this.currentConfig) const newConfig = JSON.stringify(config) if (oldConfig === newConfig) { @@ -70,20 +94,4 @@ export class BaseConfigWatcher implements ConfigWatcher { this.currentConfig = config this.configChangeEvent.fire(config) } - - get(): C { - return this.currentConfig - } - - async initAndOnChange( - callback: (config: C) => Promise, - disposables: vscode.Disposable[] - ): Promise { - await callback(this.currentConfig) - this.onChange(callback, disposables) - } - - public onChange(callback: (config: C) => Promise, disposables: vscode.Disposable[]): void { - disposables.push(this.configChangeEvent.event(callback)) - } } diff --git a/vscode/src/extension.node.ts b/vscode/src/extension.node.ts index 222dfa4ac0e..80cce12eb24 100644 --- a/vscode/src/extension.node.ts +++ b/vscode/src/extension.node.ts @@ -23,7 +23,7 @@ import { createLocalEmbeddingsController, } from './local-context/local-embeddings' import { SymfRunner } from './local-context/symf' -import { authProvider } from './services/AuthProvider' +import { AuthProvider } from './services/AuthProvider' import { localStorage } from './services/LocalStorageProvider' import { OpenTelemetryService } from './services/open-telemetry/OpenTelemetryService.node' import { getExtensionDetails } from './services/telemetry-v2' @@ -84,7 +84,7 @@ export function activate( // The vscode API is not available in the post-uninstall script. export async function deactivate(): Promise { const config = localStorage.getConfig() ?? (await getFullConfig()) - const authStatus = authProvider?.getAuthStatus() ?? defaultAuthStatus + const authStatus = AuthProvider.instance?.getAuthStatus() ?? defaultAuthStatus const { anonymousUserID } = await localStorage.anonymousUserID() serializeConfigSnapshot({ config, diff --git a/vscode/src/external-services.ts b/vscode/src/external-services.ts index c6c96558604..d985711a97c 100644 --- a/vscode/src/external-services.ts +++ b/vscode/src/external-services.ts @@ -12,6 +12,7 @@ import { } from '@sourcegraph/cody-shared' import { createClient as createCodeCompletionsClient } from './completions/client' +import type { ConfigWatcher } from './configwatcher' import type { PlatformContext } from './extension.common' import type { ContextRankerConfig } from './local-context/context-ranking' import type { ContextRankingController } from './local-context/context-ranking' @@ -48,7 +49,7 @@ type ExternalServicesConfiguration = Pick< export async function configureExternalServices( context: vscode.ExtensionContext, - initialConfig: ExternalServicesConfiguration, + config: ConfigWatcher, platform: Pick< PlatformContext, | 'createLocalEmbeddingsController' @@ -60,17 +61,13 @@ export async function configureExternalServices( >, authProvider: AuthProvider ): Promise { + const initialConfig = config.get() const sentryService = platform.createSentryService?.(initialConfig) const openTelemetryService = platform.createOpenTelemetryService?.(initialConfig) const completionsClient = platform.createCompletionsClient(initialConfig, logger) const codeCompletionsClient = createCodeCompletionsClient(initialConfig, logger) - const symfRunner = platform.createSymfRunner?.( - context, - initialConfig.serverEndpoint, - initialConfig.accessToken, - completionsClient - ) + const symfRunner = platform.createSymfRunner?.(context, config, completionsClient) if (initialConfig.codebase && isError(await graphqlClient.getRepoId(initialConfig.codebase))) { logDebug( diff --git a/vscode/src/local-context/symf.ts b/vscode/src/local-context/symf.ts index 6e21566a885..d549e1756f9 100644 --- a/vscode/src/local-context/symf.ts +++ b/vscode/src/local-context/symf.ts @@ -28,6 +28,7 @@ import { import { logDebug } from '../log' import path from 'node:path' +import type { ConfigWatcher } from '../configwatcher' import { getEditor } from '../editor/active-editor' import { getSymfPath } from './download-symf' import { rewriteKeywordQuery } from './rewrite-keyword-query' @@ -61,12 +62,14 @@ export class SymfRunner implements IndexedKeywordContextFetcher, vscode.Disposab private status: IndexStatus = new IndexStatus() - private indexManagementDisposable: vscode.Disposable + private disposables: vscode.Disposable[] = [] constructor( private context: vscode.ExtensionContext, - private sourcegraphServerEndpoint: string | null, - private authToken: string | null, + private config: ConfigWatcher<{ + serverEndpoint: string + accessToken: string | null + }>, private completionsClient: SourcegraphCompletionsClient ) { const indexRoot = vscode.Uri.joinPath(context.globalStorageUri, 'symf', 'indexroot').with( @@ -80,12 +83,15 @@ export class SymfRunner implements IndexedKeywordContextFetcher, vscode.Disposab } this.indexRoot = indexRoot - this.indexManagementDisposable = initializeSymfIndexManagement(this) + this.disposables.push(initializeSymfIndexManagement(this)) } public dispose(): void { this.status.dispose() - this.indexManagementDisposable.dispose() + for (const disposable of this.disposables) { + disposable.dispose() + } + this.disposables = [] } public onIndexStart(cb: (e: IndexStartEvent) => void): vscode.Disposable { @@ -96,21 +102,15 @@ export class SymfRunner implements IndexedKeywordContextFetcher, vscode.Disposab return this.status.onDidEnd(cb) } - public setSourcegraphAuth(endpoint: string | null, authToken: string | null): void { - this.sourcegraphServerEndpoint = endpoint - this.authToken = authToken - } - private async getSymfInfo(): Promise<{ symfPath: string serverEndpoint: string accessToken: string }> { - const accessToken = this.authToken + const { accessToken, serverEndpoint } = this.config.get() if (!accessToken) { throw new Error('SymfRunner.getResults: No access token') } - const serverEndpoint = this.sourcegraphServerEndpoint if (!serverEndpoint) { throw new Error('SymfRunner.getResults: No Sourcegraph server endpoint') } diff --git a/vscode/src/main.ts b/vscode/src/main.ts index 8b2fcc9f8dd..99cabeadead 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -1,9 +1,9 @@ import * as vscode from 'vscode' import { - type AuthStatus, type ChatClient, ClientConfigSingleton, + type CodeCompletionsClient, type ConfigurationWithAccessToken, type DefaultCodyCommands, type Guardrails, @@ -60,7 +60,7 @@ import type { SymfRunner } from './local-context/symf' import { logDebug, logError } from './log' import { MinionOrchestrator } from './minion/MinionOrchestrator' import { PoorMansBash } from './minion/environment' -import { registerModelsFromVSCodeConfiguration, syncModels } from './models/sync' +import { registerModelsFromVSCodeConfiguration } from './models/sync' import { CodyProExpirationNotifications } from './notifications/cody-pro-expiration' import { showSetupNotification } from './notifications/setup-notification' import { initVSCodeGitApi } from './repository/git-extension-api' @@ -72,7 +72,7 @@ import { displayHistoryQuickPick } from './services/HistoryChat' import { localStorage } from './services/LocalStorageProvider' import { VSCodeSecretStorage, secretStorage } from './services/SecretStorageProvider' import { registerSidebarCommands } from './services/SidebarCommands' -import { createStatusBar } from './services/StatusBar' +import { type CodyStatusBar, createStatusBar } from './services/StatusBar' import { upstreamHealthProvider } from './services/UpstreamHealthProvider' import { autocompleteStageCounterLogger } from './services/autocomplete-stage-counter-logger' import { setUpCodyIgnore } from './services/cody-ignore' @@ -108,15 +108,34 @@ export async function start( const disposables: vscode.Disposable[] = [] - const configWatcher = await BaseConfigWatcher.create(getFullConfig, disposables) + const authProvider = AuthProvider.create(await getFullConfig()) + const configWatcher = await BaseConfigWatcher.create(authProvider, disposables) + const isExtensionModeDevOrTest = + context.extensionMode === vscode.ExtensionMode.Development || + context.extensionMode === vscode.ExtensionMode.Test + await configWatcher.onChange( + async config => { + await configureEventsInfra(config, isExtensionModeDevOrTest, authProvider) + }, + disposables, + { runImmediately: true } + ) + // The split between AuthProvider construction and initialization is + // awkward, but exists so we can initialize the telemetry recorder + // first, as it is used in AuthProvider before AuthProvider initialization + // is complete. It is also important that AuthProvider inintialization + // completes before initializeSingletons is called, because many of + // those assume an initialized AuthProvider + await authProvider.init() configWatcher.onChange(async config => { platform.onConfigurationChange?.(config) registerModelsFromVSCodeConfiguration() }, disposables) - const { disposable } = await register(context, configWatcher, platform) - disposables.push(disposable) + disposables.push( + await register(context, authProvider, configWatcher, platform, isExtensionModeDevOrTest) + ) return vscode.Disposable.from(...disposables) } @@ -124,67 +143,32 @@ export async function start( // Registers commands and webview given the config. const register = async ( context: vscode.ExtensionContext, + authProvider: AuthProvider, configWatcher: ConfigWatcher, - platform: PlatformContext -): Promise<{ - disposable: vscode.Disposable -}> => { + platform: PlatformContext, + isExtensionModeDevOrTest: boolean +): Promise => { const disposables: vscode.Disposable[] = [] - const initialConfig = configWatcher.get() - const isExtensionModeDevOrTest = - context.extensionMode === vscode.ExtensionMode.Development || - context.extensionMode === vscode.ExtensionMode.Test - setClientNameVersion(platform.extensionClient.clientName, platform.extensionClient.clientVersion) - const authProvider = AuthProvider.create(initialConfig) // Initialize `displayPath` first because it might be used to display paths in error messages // from the subsequent initialization. disposables.push(manageDisplayPathEnvInfoForExtension()) - // Init local storage - await configWatcher.initAndOnChange(async config => { - await localStorage.setConfig(config) - }, disposables) + // Initialize singletons + await initializeSingletons( + platform, + authProvider, + configWatcher, + isExtensionModeDevOrTest, + disposables + ) // Ensure Git API is available disposables.push(await initVSCodeGitApi()) - // Telemetry - await configWatcher.initAndOnChange(async config => { - await configureEventsInfra(config, isExtensionModeDevOrTest, authProvider) - }, disposables) - registerParserListeners(disposables) - - // Enable tracking for pasting chat responses into editor text - disposables.push( - vscode.workspace.onDidChangeTextDocument(async e => { - const changedText = e.contentChanges[0]?.text - // Skip if the document is not a file or if the copied text is from insert - if (!changedText || e.document.uri.scheme !== 'file') { - return - } - await onTextDocumentChange(changedText) - }) - ) - - await authProvider.init() - - if (authProvider.getAuthStatus().authenticated) { - await exposeOpenCtxClient( - context, - initialConfig, - authProvider.getAuthStatus().isDotCom, - platform.createOpenCtxController - ) - } - - await configWatcher.initAndOnChange(async config => { - graphqlClient.onConfigurationChange(config) - // githubClient.onConfigurationChange({ authToken: config.experimentalGithubAccessToken }) - await featureFlagProvider.syncAuthStatus() - }, disposables) + registerChatListeners(disposables) // Initialize external services const { @@ -196,31 +180,15 @@ const register = async ( contextRanking, onConfigurationChange: externalServicesOnDidConfigurationChange, symfRunner, - } = await configureExternalServices(context, initialConfig, platform, authProvider) + } = await configureExternalServices(context, configWatcher, platform, authProvider) configWatcher.onChange(async config => { externalServicesOnDidConfigurationChange(config) - symfRunner?.setSourcegraphAuth(config.serverEndpoint, config.accessToken) + localEmbeddings?.setAccessToken(config.serverEndpoint, config.accessToken) }, disposables) - if (symfRunner) { disposables.push(symfRunner) } - // Initialize Minion - if (initialConfig.experimentalMinionAnthropicKey) { - const minionOrchestrator = new MinionOrchestrator(context.extensionUri, authProvider, symfRunner) - disposables.push(minionOrchestrator) - disposables.push( - vscode.commands.registerCommand('cody.minion.panel.new', () => - minionOrchestrator.createNewMinionPanel() - ), - vscode.commands.registerCommand('cody.minion.new-terminal', async () => { - const t = new PoorMansBash() - await t.run('hello world') - }) - ) - } - // Initialize enterprise context const enterpriseContextFactory = new EnterpriseContextFactory(completionsClient) disposables.push(enterpriseContextFactory) @@ -228,16 +196,7 @@ const register = async ( enterpriseContextFactory.clientConfigurationDidChange() }, disposables) - // Initialize context provider const editor = new VSCodeEditor() - disposables.push(contextFiltersProvider) - await contextFiltersProvider.init(repoNameResolver.getRepoNamesFromWorkspaceUri) - - configWatcher.onChange(async config => { - await contextFiltersProvider.init(repoNameResolver.getRepoNamesFromWorkspaceUri) - await localEmbeddings?.setAccessToken(config.serverEndpoint, config.accessToken) - }, disposables) - const { chatsController } = registerChat( { context, @@ -257,62 +216,196 @@ const register = async ( const sourceControl = new CodySourceControl(chatClient) const statusBar = createStatusBar() + disposables.push( + statusBar, + sourceControl, + authProvider.onChange( + authStatus => { + sourceControl.setAuthStatus(authStatus) + statusBar.setAuthStatus(authStatus) + }, + { + runImmediately: true, + } + ) + ) - // Allow the VS Code app's instance of ModelsService to use local storage to persist - // user's model choices - ModelsService.setStorage(localStorage) + const autocompleteSetup = registerAutocomplete( + configWatcher, + platform, + authProvider, + statusBar, + codeCompletionsClient, + disposables + ) + const tutorialSetup = tryRegisterTutorial(context, disposables) + const openCtxSetup = registerOpenCtxClient( + context, + platform, + configWatcher, + authProvider, + disposables + ) - // Functions that need to be called on auth status changes - const handleAuthStatusChange = async (authStatus: AuthStatus) => { - // NOTE: MUST update the config and graphQL client first. - const newConfig = await getFullConfig() - // Propagate access token through config - configWatcher.set(newConfig) - // Sync auth status to graphqlClient - graphqlClient.onConfigurationChange(newConfig) + registerCodyCommands(configWatcher, statusBar, sourceControl, chatClient, disposables) + registerAuthCommands(authProvider, disposables) + registerChatCommands(authProvider, disposables) + disposables.push(...registerSidebarCommands()) + disposables.push(...setUpCodyIgnore(configWatcher.get())) + registerOtherCommands(disposables) + if (isExtensionModeDevOrTest) { + await registerTestCommands(context, authProvider, disposables) + } + registerUpgradeHandlers(configWatcher, authProvider, disposables) + disposables.push(new CharactersLogger()) - // Refresh server configuration that controls features enablement and models. - await ClientConfigSingleton.getInstance().syncAuthStatus(authStatus) + // INC-267 do NOT await on this promise. This promise triggers + // `vscode.window.showInformationMessage()`, which only resolves after the + // user has clicked on "Setup". Awaiting on this promise will make the Cody + // extension timeout during activation. + void showSetupNotification(configWatcher.get()) - // Reset models list based on the updated auth status and server configuration. - await syncModels(authStatus) - await chatsController.syncAuthStatus(authStatus) + const [extensionClientDispose] = await Promise.all([ + platform.extensionClient.provide({ enterpriseContextFactory }), + autocompleteSetup, + openCtxSetup, + tutorialSetup, + registerMinion(context, configWatcher, authProvider, symfRunner, disposables), + ]) + disposables.push(extensionClientDispose) - const parallelTasks: Promise[] = [ - featureFlagProvider.syncAuthStatus(), - setupAutocomplete(), - ] - await Promise.all(parallelTasks) + return vscode.Disposable.from(...disposables) +} - symfRunner?.setSourcegraphAuth(authStatus.endpoint, newConfig.accessToken) +async function initializeSingletons( + platform: PlatformContext, + authProvider: AuthProvider, + configWatcher: ConfigWatcher, + isExtensionModeDevOrTest: boolean, + disposables: vscode.Disposable[] +): Promise { + // Allow the VS Code app's instance of ModelsService to use local storage to persist + // user's model choices + ModelsService.setStorage(localStorage) - void exposeOpenCtxClient( - context, - newConfig, - authStatus.isDotCom, - platform.createOpenCtxController - ) + disposables.push(upstreamHealthProvider, contextFiltersProvider) + setCommandController(platform.createCommandsProvider?.()) + repoNameResolver.init(authProvider) + await configWatcher.onChange( + config => { + const promises: Promise[] = [] + + promises.push(localStorage.setConfig(config)) + graphqlClient.setConfig(config) + promises.push( + featureFlagProvider + .refresh() + .then(() => + PromptMixin.updateContextPreamble( + isExtensionModeDevOrTest || isRunningInsideAgent() + ) + ) + ) + promises.push(contextFiltersProvider.init(repoNameResolver.getRepoNamesFromWorkspaceUri)) + ModelsService.onConfigChange() + upstreamHealthProvider.onConfigurationChange(config) - statusBar.syncAuthStatus(authStatus) - sourceControl.syncAuthStatus(authStatus) - await PromptMixin.updateContextPreamble(isExtensionModeDevOrTest || isRunningInsideAgent()) - - const eventValue = - !authStatus.isLoggedIn && !authStatus.endpoint - ? 'disconnected' - : authStatus.isLoggedIn && - !(authStatus.showNetworkError || authStatus.showInvalidAccessTokenError) - ? 'connected' - : 'failed' - telemetryRecorder.recordEvent('cody.auth', eventValue) - } + return Promise.all(promises).then() + }, + disposables, + { runImmediately: true } + ) +} - // Add change listener to auth provider - authProvider.addChangeListener(handleAuthStatusChange) +// Registers listeners to trigger parsing of visible documents +function registerParserListeners(disposables: vscode.Disposable[]) { + void parseAllVisibleDocuments() + disposables.push(vscode.window.onDidChangeVisibleTextEditors(parseAllVisibleDocuments)) + disposables.push(vscode.workspace.onDidChangeTextDocument(updateParseTreeOnEdit)) +} - setCommandController(platform.createCommandsProvider?.()) - repoNameResolver.init(authProvider) +function registerChatListeners(disposables: vscode.Disposable[]) { + // Enable tracking for pasting chat responses into editor text + disposables.push( + vscode.workspace.onDidChangeTextDocument(async e => { + const changedText = e.contentChanges[0]?.text + // Skip if the document is not a file or if the copied text is from insert + if (!changedText || e.document.uri.scheme !== 'file') { + return + } + await onTextDocumentChange(changedText) + }) + ) +} + +async function registerOtherCommands(disposables: vscode.Disposable[]) { + disposables.push( + // Account links + vscode.commands.registerCommand( + 'cody.show-rate-limit-modal', + async (userMessage: string, retryMessage: string, upgradeAvailable: boolean) => { + if (upgradeAvailable) { + const option = await vscode.window.showInformationMessage( + 'Upgrade to Cody Pro', + { + modal: true, + detail: `${userMessage}\n\nUpgrade to Cody Pro for unlimited autocomplete suggestions, chat messages and commands.\n\n${retryMessage}`, + }, + 'Upgrade', + 'See Plans' + ) + // Both options go to the same URL + if (option) { + void vscode.env.openExternal(vscode.Uri.parse(ACCOUNT_UPGRADE_URL.toString())) + } + } else { + const option = await vscode.window.showInformationMessage( + 'Rate Limit Exceeded', + { + modal: true, + detail: `${userMessage}\n\n${retryMessage}`, + }, + 'Learn More' + ) + if (option) { + void vscode.env.openExternal( + vscode.Uri.parse(ACCOUNT_LIMITS_INFO_URL.toString()) + ) + } + } + } + ), + // Walkthrough / Support + vscode.commands.registerCommand('cody.feedback', () => + vscode.env.openExternal(vscode.Uri.parse(CODY_FEEDBACK_URL.href)) + ), + vscode.commands.registerCommand('cody.welcome', async () => { + telemetryRecorder.recordEvent('cody.walkthrough', 'clicked') + // Hack: We have to run this twice to force VS Code to register the walkthrough + // Open issue: https://github.com/microsoft/vscode/issues/186165 + await vscode.commands.executeCommand('workbench.action.openWalkthrough') + return vscode.commands.executeCommand( + 'workbench.action.openWalkthrough', + 'sourcegraph.cody-ai#welcome', + false + ) + }), + // StatusBar Commands + vscode.commands.registerCommand('cody.statusBar.ollamaDocs', () => { + vscode.commands.executeCommand('vscode.open', CODY_OLLAMA_DOCS_URL.href) + telemetryRecorder.recordEvent('cody.statusBar.ollamaDocs', 'opened') + }) + ) +} + +function registerCodyCommands( + config: ConfigWatcher, + statusBar: CodyStatusBar, + sourceControl: CodySourceControl, + chatClient: ChatClient, + disposables: vscode.Disposable[] +): void { // Execute Cody Commands and Cody Custom Commands const executeCommand = ( commandKey: DefaultCodyCommands | string, @@ -344,7 +437,7 @@ const register = async ( } // Initialize supercompletion provider if experimental feature is enabled - if (initialConfig.experimentalSupercompletions) { + if (config.get().experimentalSupercompletions) { disposables.push(new SupercompletionProvider({ statusBar, chat: chatClient })) } @@ -361,37 +454,35 @@ const register = async ( vscode.commands.registerCommand('cody.command.auto-edit', a => executeAutoEditCommand(a)), sourceControl // Generate Commit Message command ) +} - // Internal-only test commands - if (isExtensionModeDevOrTest) { - await vscode.commands.executeCommand('setContext', 'cody.devOrTest', true) - disposables.push( - vscode.commands.registerCommand('cody.test.set-context-filters', async () => { - // Prompt the user for the policy - const raw = await vscode.window.showInputBox({ title: 'Context Filters Overwrite' }) - if (!raw) { - return - } - try { - const policy = JSON.parse(raw) - contextFiltersProvider.setTestingContextFilters(policy) - } catch (error) { - vscode.window.showErrorMessage( - 'Failed to parse context filters policy. Please check your JSON syntax.' - ) - } +function registerChatCommands(authProvider: AuthProvider, disposables: vscode.Disposable[]): void { + disposables.push( + // Chat + vscode.commands.registerCommand('cody.settings.extension', () => + vscode.commands.executeCommand('workbench.action.openSettings', { + query: '@ext:sourcegraph.cody-ai', }) + ), + vscode.commands.registerCommand('cody.chat.view.popOut', async () => { + vscode.commands.executeCommand('workbench.action.moveEditorToNewWindow') + }), + vscode.commands.registerCommand('cody.chat.history.panel', async () => { + await displayHistoryQuickPick(authProvider.getAuthStatus()) + }), + vscode.commands.registerCommand('cody.settings.extension.chat', () => + vscode.commands.executeCommand('workbench.action.openSettings', { + query: '@ext:sourcegraph.cody-ai chat', + }) + ), + vscode.commands.registerCommand('cody.copy.version', () => + vscode.env.clipboard.writeText(version) ) - } + ) +} +function registerAuthCommands(authProvider: AuthProvider, disposables: vscode.Disposable[]): void { disposables.push( - // Tests - // Access token - this is only used in configuration tests - vscode.commands.registerCommand('cody.test.token', async (endpoint, token) => - authProvider.auth({ endpoint, token }) - ), - - // Auth vscode.commands.registerCommand('cody.auth.signin', () => authProvider.signinMenu()), vscode.commands.registerCommand('cody.auth.signout', () => authProvider.signoutMenu()), vscode.commands.registerCommand('cody.auth.account', () => authProvider.accountMenu()), @@ -414,99 +505,26 @@ const register = async ( }) ).authStatus } - ), - // Chat - vscode.commands.registerCommand('cody.settings.extension', () => - vscode.commands.executeCommand('workbench.action.openSettings', { - query: '@ext:sourcegraph.cody-ai', - }) - ), - vscode.commands.registerCommand('cody.chat.view.popOut', async () => { - vscode.commands.executeCommand('workbench.action.moveEditorToNewWindow') - }), - vscode.commands.registerCommand('cody.chat.history.panel', async () => { - await displayHistoryQuickPick(authProvider.getAuthStatus()) - }), - vscode.commands.registerCommand('cody.settings.extension.chat', () => - vscode.commands.executeCommand('workbench.action.openSettings', { - query: '@ext:sourcegraph.cody-ai chat', - }) - ), - vscode.commands.registerCommand('cody.copy.version', () => - vscode.env.clipboard.writeText(version) - ), - - // Account links - ...registerSidebarCommands(), - - // Account links - vscode.commands.registerCommand( - 'cody.show-rate-limit-modal', - async (userMessage: string, retryMessage: string, upgradeAvailable: boolean) => { - if (upgradeAvailable) { - const option = await vscode.window.showInformationMessage( - 'Upgrade to Cody Pro', - { - modal: true, - detail: `${userMessage}\n\nUpgrade to Cody Pro for unlimited autocomplete suggestions, chat messages and commands.\n\n${retryMessage}`, - }, - 'Upgrade', - 'See Plans' - ) - // Both options go to the same URL - if (option) { - void vscode.env.openExternal(vscode.Uri.parse(ACCOUNT_UPGRADE_URL.toString())) - } - } else { - const option = await vscode.window.showInformationMessage( - 'Rate Limit Exceeded', - { - modal: true, - detail: `${userMessage}\n\n${retryMessage}`, - }, - 'Learn More' - ) - if (option) { - void vscode.env.openExternal( - vscode.Uri.parse(ACCOUNT_LIMITS_INFO_URL.toString()) - ) - } - } - } - ), + ) + ) +} +function registerUpgradeHandlers( + configWatcher: ConfigWatcher, + authProvider: AuthProvider, + disposables: vscode.Disposable[] +): void { + disposables.push( // Register URI Handler (e.g. vscode://sourcegraph.cody-ai) vscode.window.registerUriHandler({ handleUri: async (uri: vscode.Uri) => { if (uri.path === '/app-done') { // This is an old re-entrypoint from App that is a no-op now. } else { - await authProvider.tokenCallbackHandler(uri, initialConfig.customHeaders) + authProvider.tokenCallbackHandler(uri, configWatcher.get().customHeaders) } }, }), - statusBar, - // Walkthrough / Support - vscode.commands.registerCommand('cody.feedback', () => - vscode.env.openExternal(vscode.Uri.parse(CODY_FEEDBACK_URL.href)) - ), - vscode.commands.registerCommand('cody.welcome', async () => { - telemetryRecorder.recordEvent('cody.walkthrough', 'clicked') - // Hack: We have to run this twice to force VS Code to register the walkthrough - // Open issue: https://github.com/microsoft/vscode/issues/186165 - await vscode.commands.executeCommand('workbench.action.openWalkthrough') - return vscode.commands.executeCommand( - 'workbench.action.openWalkthrough', - 'sourcegraph.cody-ai#welcome', - false - ) - }), - - // StatusBar Commands - vscode.commands.registerCommand('cody.statusBar.ollamaDocs', () => { - vscode.commands.executeCommand('vscode.open', CODY_OLLAMA_DOCS_URL.href) - telemetryRecorder.recordEvent('cody.statusBar.ollamaDocs', 'opened') - }), // Check if user has just moved back from a browser window to upgrade cody pro vscode.window.onDidChangeWindowState(async ws => { @@ -530,20 +548,73 @@ const register = async ( featureFlagProvider, vscode.window.showInformationMessage, vscode.env.openExternal + ) + ) +} + +/** + * Register commands used in internal tests + */ +async function registerTestCommands( + context: vscode.ExtensionContext, + authProvider: AuthProvider, + disposables: vscode.Disposable[] +): Promise { + await vscode.commands.executeCommand('setContext', 'cody.devOrTest', true) + disposables.push( + vscode.commands.registerCommand('cody.test.set-context-filters', async () => { + // Prompt the user for the policy + const raw = await vscode.window.showInputBox({ title: 'Context Filters Overwrite' }) + if (!raw) { + return + } + try { + const policy = JSON.parse(raw) + contextFiltersProvider.setTestingContextFilters(policy) + } catch (error) { + vscode.window.showErrorMessage( + 'Failed to parse context filters policy. Please check your JSON syntax.' + ) + } + }), + // Access token - this is only used in configuration tests + vscode.commands.registerCommand('cody.test.token', async (endpoint, token) => + authProvider.auth({ endpoint, token }) ), - ...setUpCodyIgnore(initialConfig), // For debugging vscode.commands.registerCommand('cody.debug.export.logs', () => exportOutputLog(context.logUri)), vscode.commands.registerCommand('cody.debug.outputChannel', () => openCodyOutputChannel()), vscode.commands.registerCommand('cody.debug.enable.all', () => enableVerboseDebugMode()), - vscode.commands.registerCommand('cody.debug.reportIssue', () => openCodyIssueReporter()), - new CharactersLogger(), - upstreamHealthProvider + vscode.commands.registerCommand('cody.debug.reportIssue', () => openCodyIssueReporter()) ) - await configWatcher.initAndOnChange(async config => { - upstreamHealthProvider.onConfigurationChange(config) - }, disposables) +} + +async function tryRegisterTutorial( + context: vscode.ExtensionContext, + disposables: vscode.Disposable[] +): Promise { + if (!isRunningInsideAgent()) { + // TODO: The interactive tutorial is currently VS Code specific, both in terms of features and keyboard shortcuts. + // Consider opening this up to support dynamic content via Cody Agent. + // This would allow us the present the same tutorial but with client-specific steps. + // Alternatively, clients may not wish to use this tutorial and instead opt for something more suitable for their environment. + const { registerInteractiveTutorial } = await import('./tutorial') + registerInteractiveTutorial(context).then(disposable => disposables.push(...disposable)) + } +} +/** + * Registers autocomplete functionality. This can be long-running, so it's recommended + * the returned promise is awaited in parallel with other tasks. + */ +function registerAutocomplete( + configWatcher: ConfigWatcher, + platform: PlatformContext, + authProvider: AuthProvider, + statusBar: CodyStatusBar, + codeCompletionsClient: CodeCompletionsClient, + disposables: vscode.Disposable[] +): Promise { let setupAutocompleteQueue = Promise.resolve() // Create a promise chain to avoid parallel execution let autocompleteDisposables: vscode.Disposable[] = [] @@ -593,8 +664,7 @@ const register = async ( config, client: codeCompletionsClient, statusBar, - authProvider, - + authProvider: authProvider, createBfgRetriever: platform.createBfgRetriever, }) ) @@ -603,8 +673,7 @@ const register = async ( config, client: codeCompletionsClient, statusBar, - authProvider, - + authProvider: authProvider, createBfgRetriever: platform.createBfgRetriever, }), autocompleteStageCounterLogger @@ -615,56 +684,55 @@ const register = async ( }) return setupAutocompleteQueue } + void configWatcher.onChange(setupAutocomplete, disposables) + return setupAutocomplete().catch(() => {}) +} - const autocompleteSetup = setupAutocomplete().catch(() => {}) - - // Setup config watcher - configWatcher.onChange(setupAutocomplete, disposables) - await configWatcher.initAndOnChange(ModelsService.onConfigChange, disposables) - - if (!isRunningInsideAgent()) { - // TODO: The interactive tutorial is currently VS Code specific, both in terms of features and keyboard shortcuts. - // Consider opening this up to support dynamic content via Cody Agent. - // This would allow us the present the same tutorial but with client-specific steps. - // Alternatively, clients may not wish to use this tutorial and instead opt for something more suitable for their environment. - const { registerInteractiveTutorial } = await import('./tutorial') - registerInteractiveTutorial(context).then(disposable => disposables.push(...disposable)) - } - - // INC-267 do NOT await on this promise. This promise triggers - // `vscode.window.showInformationMessage()`, which only resolves after the - // user has clicked on "Setup". Awaiting on this promise will make the Cody - // extension timeout during activation. - void showSetupNotification(initialConfig) - - // Register a serializer for reviving the chat panel on reload - if (vscode.window.registerWebviewPanelSerializer) { - vscode.window.registerWebviewPanelSerializer(CodyChatEditorViewType, { - async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, chatID: string) { - if (chatID && webviewPanel.title) { - logDebug('main:deserializeWebviewPanel', 'reviving last unclosed chat panel') - await chatsController.restoreToPanel(webviewPanel, chatID) - } - }, - }) - } - - const [_, extensionClientDispose] = await Promise.all([ - autocompleteSetup, - platform.extensionClient.provide({ enterpriseContextFactory }), - ]) - disposables.push(extensionClientDispose) - - return { - disposable: vscode.Disposable.from(...disposables), +async function registerOpenCtxClient( + context: vscode.ExtensionContext, + platform: PlatformContext, + config: ConfigWatcher, + authProvider: AuthProvider, + disposables: vscode.Disposable[] +): Promise { + if (authProvider.getAuthStatus().authenticated) { + await exposeOpenCtxClient( + context, + config.get(), + authProvider.getAuthStatus().isDotCom, + platform.createOpenCtxController + ) } + config.onChange(async newConfig => { + await exposeOpenCtxClient( + context, + newConfig, + authProvider.getAuthStatus().isDotCom, + platform.createOpenCtxController + ) + }, disposables) } -// Registers listeners to trigger parsing of visible documents -function registerParserListeners(disposables: vscode.Disposable[]) { - void parseAllVisibleDocuments() - disposables.push(vscode.window.onDidChangeVisibleTextEditors(parseAllVisibleDocuments)) - disposables.push(vscode.workspace.onDidChangeTextDocument(updateParseTreeOnEdit)) +async function registerMinion( + context: vscode.ExtensionContext, + config: ConfigWatcher, + authProvider: AuthProvider, + symfRunner: SymfRunner | undefined, + disposables: vscode.Disposable[] +): Promise { + if (config.get().experimentalMinionAnthropicKey) { + const minionOrchestrator = new MinionOrchestrator(context.extensionUri, authProvider, symfRunner) + disposables.push(minionOrchestrator) + disposables.push( + vscode.commands.registerCommand('cody.minion.panel.new', () => + minionOrchestrator.createNewMinionPanel() + ), + vscode.commands.registerCommand('cody.minion.new-terminal', async () => { + const t = new PoorMansBash() + await t.run('hello world') + }) + ) + } } interface RegisterChatOptions { @@ -696,7 +764,6 @@ function registerChat( disposables: vscode.Disposable[] ): { chatsController: ChatsController - editorManager: EditManager } { // Shared configuration that is required for chat views to send and receive messages const messageProviderOptions: MessageProviderOptions = { @@ -712,6 +779,7 @@ function registerChat( startTokenReceiver: platform.startTokenReceiver, }, chatClient, + authProvider, enterpriseContextFactory, localEmbeddings || null, contextRanking || null, @@ -735,7 +803,19 @@ function registerChat( localEmbeddings.start() } - return { chatsController, editorManager } + // Register a serializer for reviving the chat panel on reload + if (vscode.window.registerWebviewPanelSerializer) { + vscode.window.registerWebviewPanelSerializer(CodyChatEditorViewType, { + async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, chatID: string) { + if (chatID && webviewPanel.title) { + logDebug('main:deserializeWebviewPanel', 'reviving last unclosed chat panel') + await chatsController.restoreToPanel(webviewPanel, chatID) + } + }, + }) + } + + return { chatsController } } /** diff --git a/vscode/src/notifications/cody-pro-expiration.test.ts b/vscode/src/notifications/cody-pro-expiration.test.ts index fb60ed5bc3c..50e4d8e3ae0 100644 --- a/vscode/src/notifications/cody-pro-expiration.test.ts +++ b/vscode/src/notifications/cody-pro-expiration.test.ts @@ -52,11 +52,13 @@ describe('Cody Pro expiration notifications', () => { }), } as unknown as SourcegraphGraphQLAPIClient authProvider = { - addChangeListener: (f: () => void) => { + onChange: (f: () => void) => { authChangeListener = f // (return an object that simulates the unsubscribe - return () => { - authChangeListener = () => {} + return { + dispose: () => { + authChangeListener = () => {} + }, } }, getAuthStatus: () => authStatus, @@ -193,7 +195,7 @@ describe('Cody Pro expiration notifications', () => { // For testing, our poll period is set to 10ms, so enable the flag and then wait // to allow that to trigger enabledFeatureFlags.add(FeatureFlag.UseSscForCodySubscription) - featureFlagProvider.syncAuthStatus() // Force clear cache of feature flags + featureFlagProvider.refresh() // Force clear cache of feature flags await new Promise(resolve => setTimeout(resolve, 20)) // Should have been called by the timer. diff --git a/vscode/src/notifications/cody-pro-expiration.ts b/vscode/src/notifications/cody-pro-expiration.ts index c1124aff330..52e607016f7 100644 --- a/vscode/src/notifications/cody-pro-expiration.ts +++ b/vscode/src/notifications/cody-pro-expiration.ts @@ -33,7 +33,7 @@ export class CodyProExpirationNotifications implements vscode.Disposable { /** * Current subscription to auth provider status changes that may trigger a check. */ - private authProviderSubscription: (() => void) | undefined + private authProviderSubscription: vscode.Disposable | undefined /** * A timer if there is currently an outstanding timed check. @@ -83,8 +83,9 @@ export class CodyProExpirationNotifications implements vscode.Disposable { // right flags. // // See https://sourcegraph.slack.com/archives/C05AGQYD528/p1706872864488829 - this.authProviderSubscription = this.authProvider.addChangeListener(() => - setTimeout(() => this.triggerExpirationCheck(), this.autoUpdateDelay) + this.authProviderSubscription = this.authProvider.onChange( + () => setTimeout(() => this.triggerExpirationCheck(), this.autoUpdateDelay), + { runImmediately: true } ) } @@ -188,7 +189,7 @@ export class CodyProExpirationNotifications implements vscode.Disposable { public dispose() { this.isDisposed = true - this.authProviderSubscription?.() + this.authProviderSubscription?.dispose() this.authProviderSubscription = undefined this.nextTimedCheck?.unref() diff --git a/vscode/src/repository/repo-name-resolver.test.ts b/vscode/src/repository/repo-name-resolver.test.ts index 88bb406a46a..72fbed1fc2d 100644 --- a/vscode/src/repository/repo-name-resolver.test.ts +++ b/vscode/src/repository/repo-name-resolver.test.ts @@ -12,7 +12,7 @@ describe('getRepoNamesFromWorkspaceUri', () => { it('resolves the repo name using graphql for enterprise accounts', async () => { const repoNameResolver = new RepoNameResolver() repoNameResolver.init({ - addChangeListener: () => () => {}, + onChange: () => () => {}, getAuthStatus: () => ({ ...defaultAuthStatus, isLoggedIn: true, isDotCom: false }), } as unknown as AuthProvider) @@ -46,7 +46,7 @@ describe('getRepoNamesFromWorkspaceUri', () => { it('resolves the repo name using local conversion function for PLG accounts', async () => { const repoNameResolver = new RepoNameResolver() repoNameResolver.init({ - addChangeListener: () => () => {}, + onChange: () => () => {}, getAuthStatus: () => ({ ...defaultAuthStatus, isLoggedIn: true, isDotCom: true }), } as unknown as AuthProvider) diff --git a/vscode/src/repository/repo-name-resolver.ts b/vscode/src/repository/repo-name-resolver.ts index 1b0d7d40fe3..9495667d70e 100644 --- a/vscode/src/repository/repo-name-resolver.ts +++ b/vscode/src/repository/repo-name-resolver.ts @@ -27,10 +27,14 @@ export class RepoNameResolver { public init(authProvider: AuthProvider): void { this.authProvider = authProvider - this.authProvider.addChangeListener(() => { - this.fsPathToRepoNameCache.clear() - this.remoteUrlToRepoNameCache.clear() - }) + // TODO(beyang): handle disposable + this.authProvider.onChange( + () => { + this.fsPathToRepoNameCache.clear() + this.remoteUrlToRepoNameCache.clear() + }, + { runImmediately: true } + ) } /** diff --git a/vscode/src/services/AuthProvider.ts b/vscode/src/services/AuthProvider.ts index 6d4c7190fbd..126bec5e0a0 100644 --- a/vscode/src/services/AuthProvider.ts +++ b/vscode/src/services/AuthProvider.ts @@ -3,12 +3,14 @@ import * as vscode from 'vscode' import { type AuthStatus, type AuthStatusProvider, + ClientConfigSingleton, CodyIDE, type ConfigurationWithAccessToken, DOTCOM_URL, LOCAL_APP_URL, SourcegraphGraphQLAPIClient, defaultAuthStatus, + graphqlClient, isError, logError, networkErrorAuthStatus, @@ -23,38 +25,52 @@ import { ACCOUNT_USAGE_URL, isLoggedIn as isAuthenticated, isSourcegraphToken } import { newAuthStatus } from '../chat/utils' import { getFullConfig } from '../configuration' import { logDebug } from '../log' +import { syncModels } from '../models/sync' import { maybeStartInteractiveTutorial } from '../tutorial/helpers' import { AuthMenu, showAccessTokenInputBox, showInstanceURLInputBox } from './AuthMenus' import { getAuthReferralCode } from './AuthProviderSimplified' import { localStorage } from './LocalStorageProvider' import { secretStorage } from './SecretStorageProvider' -type Listener = (authStatus: AuthStatus) => void -type Unsubscribe = () => void - const HAS_AUTHENTICATED_BEFORE_KEY = 'has-authenticated-before' +interface OnChangeOptions { + runImmediately: boolean +} + type AuthConfig = Pick -export class AuthProvider implements AuthStatusProvider { +export class AuthProvider implements AuthStatusProvider, vscode.Disposable { private endpointHistory: string[] = [] - private client: SourcegraphGraphQLAPIClient | null = null + private status: AuthStatus = defaultAuthStatus + private readonly didChangeEvent: vscode.EventEmitter = + new vscode.EventEmitter() + private disposables: vscode.Disposable[] = [this.didChangeEvent] + + private static _instance: AuthProvider | null = null + public static get instance(): AuthProvider | null { + return AuthProvider._instance + } - private authStatus: AuthStatus = defaultAuthStatus - private listeners: Set = new Set() - - static create(config: AuthConfig) { - if (!authProvider) { - authProvider = new AuthProvider(config) + public static create(config: AuthConfig): AuthProvider { + if (!AuthProvider._instance) { + AuthProvider._instance = new AuthProvider(config) } - return authProvider + return AuthProvider._instance } private constructor(private config: AuthConfig) { - this.authStatus.endpoint = 'init' + this.status.endpoint = 'init' this.loadEndpointHistory() } + public dispose(): void { + for (const d of this.disposables) { + d.dispose() + } + this.disposables = [] + } + // Sign into the last endpoint the user was signed into, if any public async init(): Promise { let lastEndpoint = localStorage?.getEndpoint() || this.config.serverEndpoint @@ -79,15 +95,19 @@ export class AuthProvider implements AuthStatusProvider { }).catch(error => logError('AuthProvider:init:failed', lastEndpoint, { verbose: error })) } - public addChangeListener(listener: Listener): Unsubscribe { - listener(this.authStatus) - this.listeners.add(listener) - return () => this.listeners.delete(listener) + public onChange( + listener: (authStatus: AuthStatus) => void, + { runImmediately }: OnChangeOptions = { runImmediately: false } + ): vscode.Disposable { + if (runImmediately) { + listener(this.status) + } + return this.didChangeEvent.event(listener) } // Display quickpick to select endpoint to sign in to public async signinMenu(type?: 'enterprise' | 'dotcom' | 'token', uri?: string): Promise { - const mode = this.authStatus.isLoggedIn ? 'switch' : 'signin' + const mode = this.status.isLoggedIn ? 'switch' : 'signin' logDebug('AuthProvider:signinMenu', mode) telemetryRecorder.recordEvent('cody.auth.login', 'clicked') const item = await AuthMenu(mode, this.endpointHistory) @@ -104,7 +124,7 @@ export class AuthProvider implements AuthStatusProvider { if (!instanceUrl) { return } - this.authStatus.endpoint = instanceUrl + this.status.endpoint = instanceUrl this.redirectToEndpointLogin(instanceUrl) break } @@ -171,7 +191,7 @@ export class AuthProvider implements AuthStatusProvider { } public async accountMenu(): Promise { - const selected = await openAccountMenu(this.authStatus) + const selected = await openAccountMenu(this.status) if (selected === undefined) { return } @@ -180,7 +200,7 @@ export class AuthProvider implements AuthStatusProvider { case AccountMenuOptions.Manage: { // Add the username to the web can warn if the logged in session on web is different from VS Code const uri = vscode.Uri.parse(ACCOUNT_USAGE_URL.toString()).with({ - query: `cody_client_user=${encodeURIComponent(this.authStatus.username)}`, + query: `cody_client_user=${encodeURIComponent(this.status.username)}`, }) void vscode.env.openExternal(uri) break @@ -198,8 +218,7 @@ export class AuthProvider implements AuthStatusProvider { private async signout(endpoint: string): Promise { await secretStorage.deleteToken(endpoint) await localStorage.deleteEndpoint() - await this.auth({ endpoint, token: null }) - this.authStatus.endpoint = '' + await this.auth({ endpoint: '', token: null }) await vscode.commands.executeCommand('setContext', 'cody.activated', false) } @@ -229,7 +248,7 @@ export class AuthProvider implements AuthStatusProvider { return { ...defaultAuthStatus, endpoint } } } - // Cache the config and the GraphQL client + // Cache the config and GraphQL client if (this.config !== config || !this.client) { this.config = config this.client = new SourcegraphGraphQLAPIClient(config) @@ -306,7 +325,7 @@ export class AuthProvider implements AuthStatusProvider { } public getAuthStatus(): AuthStatus { - return this.authStatus + return this.status } // It processes the authentication steps and stores the login info before sharing the auth status with chatview @@ -338,7 +357,7 @@ export class AuthProvider implements AuthStatusProvider { await this.storeAuthInfo(config.serverEndpoint, config.accessToken) } - this.syncAuthStatus(authStatus) + this.setAuthStatus(authStatus) await vscode.commands.executeCommand('setContext', 'cody.activated', isLoggedIn) // If the extension is authenticated on startup, it can't be a user's first @@ -374,21 +393,38 @@ export class AuthProvider implements AuthStatusProvider { } // Set auth status and share it with chatview - private syncAuthStatus(authStatus: AuthStatus): void { - if (this.authStatus === authStatus) { + private setAuthStatus(authStatus: AuthStatus): void { + if (this.status === authStatus) { return } - this.authStatus = authStatus - this.announceNewAuthStatus() - } + this.status = authStatus - public announceNewAuthStatus(): void { - if (this.authStatus.endpoint === 'init') { + if (authStatus.endpoint === 'init') { return } - const authStatus = this.getAuthStatus() - for (const listener of this.listeners) { - listener(authStatus) + void this.updateAuthStatus(authStatus) + } + + private async updateAuthStatus(authStatus: AuthStatus): Promise { + try { + // We update the graphqlClient and ModelsService first + // because many listeners rely on these + graphqlClient.setConfig(await getFullConfig()) + await ClientConfigSingleton.getInstance().setAuthStatus(authStatus) + await syncModels(authStatus) + } catch (error) { + logDebug('AuthProvider', 'updateAuthStatus error', error) + } finally { + this.didChangeEvent.fire(this.getAuthStatus()) + let eventValue: 'disconnected' | 'connected' | 'failed' + if (authStatus.showNetworkError || authStatus.showInvalidAccessTokenError) { + eventValue = 'failed' + } else if (authStatus.isLoggedIn) { + eventValue = 'connected' + } else { + eventValue = 'disconnected' + } + telemetryRecorder.recordEvent('cody.auth', eventValue) } } @@ -402,7 +438,7 @@ export class AuthProvider implements AuthStatusProvider { const params = new URLSearchParams(uri.query) const token = params.get('code') - const endpoint = this.authStatus.endpoint + const endpoint = this.status.endpoint if (!token || !endpoint) { return } @@ -438,7 +474,7 @@ export class AuthProvider implements AuthStatusProvider { const newTokenCallbackUrl = new URL('/user/settings/tokens/new/callback', endpoint) newTokenCallbackUrl.searchParams.append('requestFrom', getAuthReferralCode()) - this.authStatus.endpoint = endpoint + this.status.endpoint = endpoint void vscode.env.openExternal(vscode.Uri.parse(newTokenCallbackUrl.href)) } @@ -471,7 +507,7 @@ export class AuthProvider implements AuthStatusProvider { // endpoint and races with everything else this class does. // Simplified onboarding only supports dotcom. - this.authStatus.endpoint = DOTCOM_URL.toString() + this.status.endpoint = DOTCOM_URL.toString() } // Logs a telemetry event if the user has never authenticated to Sourcegraph. @@ -489,10 +525,6 @@ export class AuthProvider implements AuthStatusProvider { return localStorage.set(HAS_AUTHENTICATED_BEFORE_KEY, 'true') } } -/** - * Singleton instance of auth provider. - */ -export let authProvider: AuthProvider | null = null export function isNetworkError(error: Error): boolean { const message = error.message diff --git a/vscode/src/services/LocalStorageProvider.test.ts b/vscode/src/services/LocalStorageProvider.test.ts index 8c1bc1945e3..b97ad2b1b6a 100644 --- a/vscode/src/services/LocalStorageProvider.test.ts +++ b/vscode/src/services/LocalStorageProvider.test.ts @@ -68,7 +68,7 @@ describe('LocalStorageProvider', () => { }) const DUMMY_AUTH_STATUS: AuthStatus = { - endpoint: null, + endpoint: '', isDotCom: true, isLoggedIn: true, isFireworksTracingEnabled: false, diff --git a/vscode/src/services/StatusBar.ts b/vscode/src/services/StatusBar.ts index 365936f3cc5..0954ba2ce9b 100644 --- a/vscode/src/services/StatusBar.ts +++ b/vscode/src/services/StatusBar.ts @@ -36,7 +36,7 @@ export interface CodyStatusBar { ): () => void addError(error: StatusBarError): () => void hasError(error: StatusBarErrorName): boolean - syncAuthStatus(newStatus: AuthStatus): void + setAuthStatus(newStatus: AuthStatus): void } const DEFAULT_TEXT = '$(cody-logo-heavy)' @@ -381,7 +381,7 @@ export function createStatusBar(): CodyStatusBar { hasError(errorName: StatusBarErrorName): boolean { return errors.some(e => e.error.errorType === errorName) }, - syncAuthStatus(newStatus: AuthStatus) { + setAuthStatus(newStatus: AuthStatus) { authStatus = newStatus rerender() }, diff --git a/vscode/src/services/tree-views/TreeViewProvider.test.ts b/vscode/src/services/tree-views/TreeViewProvider.test.ts index 9cd4b567e03..f26138ed8ed 100644 --- a/vscode/src/services/tree-views/TreeViewProvider.test.ts +++ b/vscode/src/services/tree-views/TreeViewProvider.test.ts @@ -49,7 +49,7 @@ describe('TreeViewProvider', () => { endpoint: URL }): Promise { const nextUpdate = waitForTreeUpdate() - tree.syncAuthStatus( + tree.setAuthStatus( newAuthStatus( endpoint.toString(), isDotCom(endpoint.toString()), diff --git a/vscode/src/services/tree-views/TreeViewProvider.ts b/vscode/src/services/tree-views/TreeViewProvider.ts index 2a224f536e9..e9db87194d3 100644 --- a/vscode/src/services/tree-views/TreeViewProvider.ts +++ b/vscode/src/services/tree-views/TreeViewProvider.ts @@ -104,7 +104,7 @@ export class TreeViewProvider implements vscode.TreeDataProvider { + public refresh(): Promise { return Promise.resolve() } } diff --git a/vscode/src/tutorial/helpers.ts b/vscode/src/tutorial/helpers.ts index 85599c827b8..cfe2b6fc7fb 100644 --- a/vscode/src/tutorial/helpers.ts +++ b/vscode/src/tutorial/helpers.ts @@ -27,7 +27,7 @@ export const isInTutorial = (document: vscode.TextDocument): boolean => { // This will either noop or open the tutorial depending on the feature flag. export const maybeStartInteractiveTutorial = async () => { telemetryRecorder.recordEvent('cody.interactiveTutorial', 'attemptingStart') - await featureFlagProvider.syncAuthStatus() + await featureFlagProvider.refresh() const enabled = await featureFlagProvider.evaluateFeatureFlag(FeatureFlag.CodyInteractiveTutorial) logFirstEnrollmentEvent(FeatureFlag.CodyInteractiveTutorial, enabled) if (!enabled) { diff --git a/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/Toolbar.tsx b/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/Toolbar.tsx index f20dd32c883..fb2b232c196 100644 --- a/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/Toolbar.tsx +++ b/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/Toolbar.tsx @@ -99,7 +99,7 @@ const ModelSelectFieldToolbarItem: FunctionComponent<{ return ( !!chatModels?.length && - (userInfo?.isDotComUser || !userInfo?.isOldStyleEnterpriseUser) && + (userInfo.isDotComUser || !userInfo.isOldStyleEnterpriseUser) && onCurrentChatModelChange && (