diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index fcf03301b5a5..01e2f0a1d24b 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -3,10 +3,9 @@ name: vscode-generate-changelog on: workflow_dispatch: inputs: - branch: - description: 'Branch to generate the changelog for' + version: + description: 'The version to be released, for example: 1.60.0' required: true - default: 'main' type: string jobs: @@ -16,15 +15,30 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.branch }} + - name: Configure git + run: | + git config --global user.name 'sourcegraph-bot' + git config --global user.email 'bot@sourcegraph.com' + - name: Update version + env: + VERSION: ${{ github.event.inputs.version }} + run: | + set +x + # git checkout -b version-update/$VERSION + sed -i 's/"version": "[0-9]\+\.[0-9]\+\.[0-9]\+"/"version": "'$VERSION'"/' vscode/package.json + # This will get tagged along with the changelog PR + git add vscode/package.json + git status + git commit -m "Update version to $VERSION" - name: Generate changelog env: - DEVX_SERVICE_GH_TOKEN: ${{ secrets.DEVX_SERVICE_GH_TOKEN }} - GH_TOKEN: ${{ secrets.DEVX_SERVICE_GH_TOKEN }} + GH_TOKEN: ${{ secrets.DEVX_SERVICE_GH_TOKEN }} GH_REPO: "sourcegraph/cody" CHANGELOG_SKIP_NO_CHANGELOG: "true" CHANGELOG_COMPACT: "true" - EXT_VERSION: ${{ env.EXT_VERSION }} + VERSION: ${{ github.event.inputs.branchenv.VERSION }} run: | + set +x # Get previous tag's commit git fetch --tags origin PREV_TAG=$(git tag --sort=-v:refname | grep '^vscode-v' | head -n 2 | tail -n 1) @@ -38,17 +52,24 @@ jobs: gh release -R sourcegraph/devx-service download ${tagName} --pattern changelog chmod +x changelog - ./changelog write \ - --output-file="vscode/CHANGELOG.md" \ - --output.changelog.marker='{/* CHANGELOG_START */}' \ - --releaseregistry.version=$EXT_VERSION + ./changelog update-as-pr \ + --github.repo=$GH_REPO \ + --output.repo.base="main" \ + --output.repo=$GH_REPO \ + --output.pr.branch="release/vscode-%s" \ + --output.pr.title="Changelog for %s" \ + --output.pr.body="Automated release and changelog for VS code Cody %s" \ + --output.changelog="vscode/CHANGELOG.md" \ + --output.changelog.marker='{/* CHANGELOG_START */}' \ + --releaseregistry.version=$VERSION + + #cat vscode/CHANGELOG.md - cat vscode/CHANGELOG.md - # git checkout -b release/vscode-v$EXT_VERSION + # git checkout -b release/vscode-v$VERSION # git add vscode/CHANGELOG.md # git commit -m "Automated release and changelog for VS code Cody" - # git push -u origin release/vscode-v$EXT_VERSION + # git push -u origin release/vscode-v$VERSION # gh pr create \ - # --title "VS Code: Release v$EXT_VERSION" \ + # --title "VS Code: Release v$VERSION" \ # --body "Automated release and changelog for VS code Cody" \ - # --base main --head release/vscode-v$EXT_VERSION + # --base main --head release/vscode-v$VERSION diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/AuthenticationError.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/AuthenticationError.kt index 3535471ab4be..1a9d1c571983 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/AuthenticationError.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/AuthenticationError.kt @@ -16,6 +16,7 @@ sealed class AuthenticationError { "network-error" -> context.deserialize(element, NetworkAuthError::class.java) "invalid-access-token" -> context.deserialize(element, InvalidAccessTokenError::class.java) "enterprise-user-logged-into-dotcom" -> context.deserialize(element, EnterpriseUserDotComError::class.java) + "auth-config-error" -> context.deserialize(element, AuthConfigError::class.java) else -> throw Exception("Unknown discriminator ${element}") } } @@ -50,3 +51,14 @@ data class EnterpriseUserDotComError( } } +data class AuthConfigError( + val title: String? = null, + val message: String, + val type: TypeEnum, // Oneof: auth-config-error +) : AuthenticationError() { + + enum class TypeEnum { + @SerializedName("auth-config-error") `Auth-config-error`, + } +} + diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyContextFilterItem.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyContextFilterItem.kt index d4f1069e5eaa..d54012c2afbb 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyContextFilterItem.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyContextFilterItem.kt @@ -9,7 +9,7 @@ data class CodyContextFilterItem( ) { enum class RepoNamePatternEnum { - @SerializedName(".*") MATCH_ALL, + @SerializedName(".*") Wildcard, } } diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt index 319506fed42e..6beac30c79ec 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt @@ -2,7 +2,7 @@ package com.sourcegraph.cody.agent.protocol_generated; object Constants { - const val MATCH_ALL = ".*" + const val Wildcard = ".*" const val Applied = "Applied" const val Applying = "Applying" const val Automatic = "Automatic" @@ -16,6 +16,7 @@ object Constants { const val agentic = "agentic" const val ask = "ask" const val assistant = "assistant" + const val `auth-config-error` = "auth-config-error" const val authenticated = "authenticated" const val autocomplete = "autocomplete" const val balanced = "balanced" diff --git a/agent/scripts/reverse-proxy.py b/agent/scripts/reverse-proxy.py old mode 100644 new mode 100755 diff --git a/agent/scripts/simple-external-auth-provider.py b/agent/scripts/simple-external-auth-provider.py new file mode 100755 index 000000000000..11209b7ed4f5 --- /dev/null +++ b/agent/scripts/simple-external-auth-provider.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import json +import time +from datetime import datetime + +def generate_credentials(): + current_epoch = int(time.time()) + 100 + + credentials = { + "headers": { + "Authorization": "Bearer SomeUser", + "Expiration": current_epoch, + }, + "expiration": current_epoch + } + + # Print JSON to stdout + print(json.dumps(credentials)) + +if __name__ == "__main__": + generate_credentials() diff --git a/agent/src/cli/scip-codegen/emitters/KotlinEmitter.ts b/agent/src/cli/scip-codegen/emitters/KotlinEmitter.ts index 7814d66d849c..e3d5b67b3d56 100644 --- a/agent/src/cli/scip-codegen/emitters/KotlinEmitter.ts +++ b/agent/src/cli/scip-codegen/emitters/KotlinEmitter.ts @@ -265,7 +265,7 @@ export class KotlinFormatter extends Formatter { } override formatFieldName(name: string): string { - const escaped = name.replace(':', '_').replace('/', '_') + const escaped = name.replace('.*', 'Wildcard').replace(':', '_').replace('/', '_') const isKeyword = this.options.reserved.has(escaped) const needsBacktick = isKeyword || !/^[a-zA-Z0-9_]+$/.test(escaped) // Replace all non-alphanumeric characters with underscores diff --git a/lib/shared/src/auth/types.ts b/lib/shared/src/auth/types.ts index 1a5bc31babf9..cca6b6bd6d3a 100644 --- a/lib/shared/src/auth/types.ts +++ b/lib/shared/src/auth/types.ts @@ -62,7 +62,15 @@ export interface EnterpriseUserDotComError { enterprise: string } -export type AuthenticationError = NetworkAuthError | InvalidAccessTokenError | EnterpriseUserDotComError +export interface AuthConfigError extends AuthenticationErrorMessage { + type: 'auth-config-error' +} + +export type AuthenticationError = + | NetworkAuthError + | InvalidAccessTokenError + | EnterpriseUserDotComError + | AuthConfigError export interface AuthenticationErrorMessage { title?: string @@ -90,6 +98,8 @@ export function getAuthErrorMessage(error: AuthenticationError): AuthenticationE "in through your organization's enterprise instance instead. If you need assistance " + 'please contact your Sourcegraph admin.', } + case 'auth-config-error': + return error } } diff --git a/lib/shared/src/configuration.ts b/lib/shared/src/configuration.ts index 3e43fc12f89d..10397e508d46 100644 --- a/lib/shared/src/configuration.ts +++ b/lib/shared/src/configuration.ts @@ -18,6 +18,7 @@ export type TokenSource = 'redirect' | 'paste' export interface AuthCredentials { serverEndpoint: string credentials: HeaderCredential | TokenCredential | undefined + error?: any } export interface HeaderCredential { diff --git a/lib/shared/src/configuration/auth-resolver.test.ts b/lib/shared/src/configuration/auth-resolver.test.ts index 52f69e429e38..9b779fe707e8 100644 --- a/lib/shared/src/configuration/auth-resolver.test.ts +++ b/lib/shared/src/configuration/auth-resolver.test.ts @@ -61,9 +61,10 @@ describe('auth-resolver', () => { }) test('resolve custom auth provider', async () => { + const futureEpoch = Date.UTC(2050) / 1000 const credentialsJson = JSON.stringify({ headers: { Authorization: 'token X' }, - expiration: 1337, + expiration: futureEpoch, }) const auth = await resolveAuth( @@ -91,11 +92,75 @@ describe('auth-resolver', () => { expect(auth.serverEndpoint).toBe('https://my-server.com/') const headerCredential = auth.credentials as HeaderCredential - expect(headerCredential.expiration).toBe(1337) + expect(headerCredential.expiration).toBe(futureEpoch) expect(headerCredential.getHeaders()).toStrictEqual({ Authorization: 'token X', }) expect(JSON.stringify(headerCredential)).not.toContain('token X') }) + + test('resolve custom auth provider error handling - bad JSON', async () => { + const auth = await resolveAuth( + 'sourcegraph.com', + { + authExternalProviders: [ + { + endpoint: 'https://my-server.com', + executable: { + commandLine: ['echo x'], + shell: isWindows() ? process.env.ComSpec : '/bin/bash', + timeout: 5000, + windowsHide: true, + }, + }, + ], + overrideServerEndpoint: 'https://my-server.com', + overrideAuthToken: undefined, + }, + new TempClientSecrets(new Map()) + ) + + expect(auth.serverEndpoint).toBe('https://my-server.com/') + + expect(auth.credentials).toBe(undefined) + expect(auth.error.message).toContain('Failed to execute external auth command: Unexpected token') + }) + + test('resolve custom auth provider error handling - bad expiration', async () => { + const expiredEpoch = Date.UTC(2020) / 1000 + const credentialsJson = JSON.stringify({ + headers: { Authorization: 'token X' }, + expiration: expiredEpoch, + }) + + const auth = await resolveAuth( + 'sourcegraph.com', + { + authExternalProviders: [ + { + endpoint: 'https://my-server.com', + executable: { + commandLine: [ + isWindows() ? `echo ${credentialsJson}` : `echo '${credentialsJson}'`, + ], + shell: isWindows() ? process.env.ComSpec : '/bin/bash', + timeout: 5000, + windowsHide: true, + }, + }, + ], + overrideServerEndpoint: 'https://my-server.com', + overrideAuthToken: undefined, + }, + new TempClientSecrets(new Map()) + ) + + expect(auth.serverEndpoint).toBe('https://my-server.com/') + + expect(auth.credentials).toBe(undefined) + expect(auth.error.message).toContain( + 'Credentials expiration cannot be set to a date in the past' + ) + }) }) diff --git a/lib/shared/src/configuration/auth-resolver.ts b/lib/shared/src/configuration/auth-resolver.ts index 414a21302262..688d255c4e5c 100644 --- a/lib/shared/src/configuration/auth-resolver.ts +++ b/lib/shared/src/configuration/auth-resolver.ts @@ -64,38 +64,56 @@ export async function resolveAuth( const { authExternalProviders, overrideServerEndpoint, overrideAuthToken } = configuration const serverEndpoint = normalizeServerEndpointURL(overrideServerEndpoint || endpoint) - if (overrideAuthToken) { - return { credentials: { token: overrideAuthToken }, serverEndpoint } - } - - const credentials = await getExternalProviderAuthResult(serverEndpoint, authExternalProviders).catch( - error => { - throw new Error(`Failed to execute external auth command: ${error}`) + try { + if (overrideAuthToken) { + return { credentials: { token: overrideAuthToken }, serverEndpoint } } - ) - if (credentials) { - return { - credentials: { - expiration: credentials?.expiration, - getHeaders() { - return credentials.headers - }, - }, + const credentials = await getExternalProviderAuthResult( serverEndpoint, + authExternalProviders + ).catch(error => { + throw new Error(`Failed to execute external auth command: ${error.message || error}`) + }) + + if (credentials) { + if (credentials?.expiration) { + const expirationMs = credentials?.expiration * 1000 + if (expirationMs < Date.now()) { + throw new Error( + 'Credentials expiration cannot be set to a date in the past: ' + + `${new Date(expirationMs)} (${credentials.expiration})` + ) + } + } + return { + credentials: { + expiration: credentials?.expiration, + getHeaders() { + return credentials.headers + }, + }, + serverEndpoint, + } } - } - const token = await clientSecrets.getToken(serverEndpoint).catch(error => { - throw new Error( - `Failed to get access token for endpoint ${serverEndpoint}: ${error.message || error}` - ) - }) + const token = await clientSecrets.getToken(serverEndpoint).catch(error => { + throw new Error( + `Failed to get access token for endpoint ${serverEndpoint}: ${error.message || error}` + ) + }) - return { - credentials: token - ? { token, source: await clientSecrets.getTokenSource(serverEndpoint) } - : undefined, - serverEndpoint, + return { + credentials: token + ? { token, source: await clientSecrets.getTokenSource(serverEndpoint) } + : undefined, + serverEndpoint, + } + } catch (error) { + return { + credentials: undefined, + serverEndpoint, + error, + } } } diff --git a/lib/shared/src/configuration/resolver.ts b/lib/shared/src/configuration/resolver.ts index 4cdc90d95e27..a6df637a102a 100644 --- a/lib/shared/src/configuration/resolver.ts +++ b/lib/shared/src/configuration/resolver.ts @@ -96,14 +96,7 @@ async function resolveConfiguration({ const auth = await resolveAuth(serverEndpoint, clientConfiguration, clientSecrets) const cred = auth.credentials if (cred !== undefined && 'expiration' in cred && cred.expiration !== undefined) { - const expirationMs = cred.expiration * 1000 - const expireInMs = expirationMs - Date.now() - if (expireInMs < 0) { - throw new Error( - 'Credentials expiration cannot be se to the past date:' + - `${new Date(expirationMs)} (${cred.expiration})` - ) - } + const expireInMs = cred.expiration * 1000 - Date.now() setInterval(() => _refreshConfigRequests.next(), expireInMs) } return { configuration: clientConfiguration, clientState, auth, isReinstall } @@ -111,7 +104,11 @@ async function resolveConfiguration({ // We don't want to throw here, because that would cause the observable to terminate and // all callers receiving no further config updates. logError('resolveConfiguration', `Error resolving configuration: ${error}`) - const auth = { credentials: undefined, serverEndpoint } + const auth = { + credentials: undefined, + serverEndpoint, + error: error, + } return { configuration: clientConfiguration, clientState, auth, isReinstall } } } diff --git a/lib/shared/src/experimentation/FeatureFlagProvider.ts b/lib/shared/src/experimentation/FeatureFlagProvider.ts index 754ed7f21310..5598b1f47cc2 100644 --- a/lib/shared/src/experimentation/FeatureFlagProvider.ts +++ b/lib/shared/src/experimentation/FeatureFlagProvider.ts @@ -99,21 +99,22 @@ export enum FeatureFlag { */ CodyPromptsV2 = 'prompt-creation-v2', - /** Whether user has access to the experimental Deep Cody feature. - * This replaces the old 'cody-deep-reflection' that was used for internal testing. + /** Whether user has access to the experimental agentic chat (fka Deep Cody) feature. + * This replaces the old 'cody-deep-reflection' & 'deep-cody' that was used for internal testing. */ - DeepCody = 'deep-cody', + DeepCody = 'agentic-chat-experimental', - /** Enable Shell Context for Deep Cody */ - DeepCodyShellContext = 'deep-cody-shell-context', + /** Enable terminal access for agentic context */ + DeepCodyShellContext = 'agentic-chat-cli-tool-experimental', /** Whether Context Agent (Deep Cody) should use the default chat model or 3.5 Haiku */ - ContextAgentDefaultChatModel = 'context-agent-use-default-chat-model', + ContextAgentDefaultChatModel = 'agentic-chat-use-default-chat-model', /** Enable Rate Limit for Deep Cody */ DeepCodyRateLimitBase = 'deep-cody-experimental-rate-limit', DeepCodyRateLimitMultiplier = 'deep-cody-experimental-rate-limit-multiplier', - AgenticContextSessionLimit = 'agentic-context-experimental-session-limit', + /** Enable Rate Limit per chat session for agentic chat */ + AgenticContextSessionLimit = 'agentic-chat-experimental-session-limit', /** * Whether the current repo context chip is shown in the chat input by default diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md index 0accabeaf3c4..e720532e7fa2 100644 --- a/vscode/CHANGELOG.md +++ b/vscode/CHANGELOG.md @@ -14,6 +14,8 @@ This is a log of all notable changes to Cody for VS Code. ### Uncategorized +{/* CHANGELOG_START */} + ## 1.58.0 ### Added diff --git a/vscode/package.json b/vscode/package.json index e2545292ae3f..a2046020b1a1 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -684,6 +684,11 @@ "command": "-github.copilot.generate", "key": "ctrl+enter" }, + { + "command": "-extension.vim_tab", + "key": "tab", + "when": "editorTextFocus && vim.active && vim.mode == 'Normal' && !inDebugRepl" + }, { "command": "cody.autocomplete.manual-trigger", "key": "alt+\\", diff --git a/vscode/src/auth/auth.ts b/vscode/src/auth/auth.ts index 822614eb42c2..414baef92c59 100644 --- a/vscode/src/auth/auth.ts +++ b/vscode/src/auth/auth.ts @@ -90,9 +90,7 @@ export async function showSignInMenu( const { configuration } = await currentResolvedConfig() const auth = await resolveAuth(selectedEndpoint, configuration, secretStorage) - let authStatus = auth.credentials - ? await authProvider.validateAndStoreCredentials(auth, 'store-if-valid') - : undefined + let authStatus = await authProvider.validateAndStoreCredentials(auth, 'store-if-valid') if (!authStatus?.authenticated) { const token = await showAccessTokenInputBox(selectedEndpoint) @@ -406,6 +404,24 @@ export async function validateCredentials( signal?: AbortSignal, clientConfig?: CodyClientConfig ): Promise { + if (config.auth.error !== undefined) { + logDebug( + 'auth', + `Failed to authenticate to ${config.auth.serverEndpoint} due to configuration error`, + config.auth.error + ) + return { + authenticated: false, + endpoint: config.auth.serverEndpoint, + pendingValidation: false, + error: { + type: 'auth-config-error', + title: 'Auth config error', + message: config.auth.error?.message ?? config.auth.error, + }, + } + } + // An access token is needed except for Cody Web, which uses cookies. if (!config.auth.credentials && !clientCapabilities().isCodyWeb) { return { authenticated: false, endpoint: config.auth.serverEndpoint, pendingValidation: false } diff --git a/vscode/src/chat/chat-view/ChatController.ts b/vscode/src/chat/chat-view/ChatController.ts index 1c901dd91bb7..c478a5a1efdf 100644 --- a/vscode/src/chat/chat-view/ChatController.ts +++ b/vscode/src/chat/chat-view/ChatController.ts @@ -363,6 +363,9 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv viewColumn: vscode.ViewColumn.Beside, }) break + case 'openRemoteFile': + this.openRemoteFile(message.uri) + break case 'newFile': await handleCodeFromSaveToNewFile(message.text, this.editor) break @@ -959,6 +962,37 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv return } + private openRemoteFile(uri: vscode.Uri) { + const json = uri.toJSON() + const searchParams = (json.query || '').split('&') + + const sourcegraphSchemaURI = vscode.Uri.from({ + ...json, + query: '', + scheme: 'codysourcegraph', + }) + + // Supported line params examples: L42 (single line) or L42-45 (line range) + const lineParam = searchParams.find((value: string) => value.match(/^L\d+(?:-\d+)?$/)?.length) + const range = this.lineParamToRange(lineParam) + + vscode.workspace.openTextDocument(sourcegraphSchemaURI).then(async doc => { + const textEditor = await vscode.window.showTextDocument(doc) + + textEditor.revealRange(range) + }) + } + + private lineParamToRange(lineParam?: string | null): vscode.Range { + const lines = (lineParam ?? '0') + .replace('L', '') + .split('-') + .map(num => Number.parseInt(num)) + + // adding 20 lines to the end of the range to allow the start line to be visible in a more center position on the screen. + return new vscode.Range(lines.at(0) || 0, 0, lines.at(1) || (lines.at(0) || 0) + 20, 0) + } + private submitOrEditOperation: AbortController | undefined public startNewSubmitOrEditOperation(): AbortSignal { this.submitOrEditOperation?.abort() diff --git a/vscode/src/chat/chat-view/sourcegraphRemoteFile.ts b/vscode/src/chat/chat-view/sourcegraphRemoteFile.ts new file mode 100644 index 000000000000..5cfb73b41af1 --- /dev/null +++ b/vscode/src/chat/chat-view/sourcegraphRemoteFile.ts @@ -0,0 +1,102 @@ +import { graphqlClient, isError } from '@sourcegraph/cody-shared' +import { LRUCache } from 'lru-cache' +import * as vscode from 'vscode' + +export class SourcegraphRemoteFileProvider implements vscode.FileSystemProvider, vscode.Disposable { + private cache = new LRUCache>({ max: 128 }) + private disposables: vscode.Disposable[] = [] + + constructor() { + this.disposables.push( + vscode.workspace.registerFileSystemProvider('codysourcegraph', this, { isReadonly: true }) + ) + } + + async readFile(uri: vscode.Uri): Promise { + const cachedResult = this.cache.get(uri.toString()) + + if (cachedResult) { + return cachedResult + } + + const contentPromise = getFileContentsFromURL(uri).then(content => + new TextEncoder().encode(content) + ) + + this.cache.set(uri.toString(), contentPromise) + + return contentPromise + } + + public dispose(): void { + this.cache.clear() + for (const disposable of this.disposables) { + disposable.dispose() + } + this.disposables = [] + } + + // Below methods are unused + + onDidChangeFile: vscode.Event = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >().event + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}) + } + + stat(uri: vscode.Uri): vscode.FileStat { + return { + type: vscode.FileType.File, + ctime: 0, + mtime: 0, + size: 0, + } + } + + readDirectory() { + return [] + } + + createDirectory() { + throw new Error('Method not implemented.') + } + + writeFile() { + throw new Error('Method not implemented.') + } + + rename() { + throw new Error('Method not implemented.') + } + + delete() { + throw new Error('Method not implemented.') + } +} + +async function getFileContentsFromURL(URL: vscode.Uri): Promise { + const path = URL.path + const [repoRev = '', filePath] = path.split('/-/blob/') + let [repoName, rev = 'HEAD'] = repoRev.split('@') + repoName = repoName.replace(/^\/+/, '') + + if (!repoName || !filePath) { + throw new Error('Invalid URI') + } + + const dataOrError = await graphqlClient.getFileContents(repoName, filePath, rev) + + if (isError(dataOrError)) { + throw new Error(dataOrError.message) + } + + const content = dataOrError.repository?.commit?.file?.content + + if (!content) { + throw new Error('File not found') + } + + return content +} diff --git a/vscode/src/chat/protocol.ts b/vscode/src/chat/protocol.ts index 2fd3aa9addc7..a9f7e8de6108 100644 --- a/vscode/src/chat/protocol.ts +++ b/vscode/src/chat/protocol.ts @@ -74,6 +74,7 @@ export type WebviewMessage = | { command: 'restoreHistory'; chatID: string } | { command: 'links'; value: string } | { command: 'openURI'; uri: Uri } + | { command: 'openRemoteFile'; uri: Uri } | { command: 'openFileLink' uri: Uri diff --git a/vscode/src/main.ts b/vscode/src/main.ts index c84053fd668b..c22275e114cb 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -53,6 +53,7 @@ import type { MessageProviderOptions } from './chat/MessageProvider' import { CodyToolProvider } from './chat/agentic/CodyToolProvider' import { ChatsController, CodyChatEditorViewType } from './chat/chat-view/ChatsController' import { ContextRetriever } from './chat/chat-view/ContextRetriever' +import { SourcegraphRemoteFileProvider } from './chat/chat-view/sourcegraphRemoteFile' import type { ChatIntentAPIClient } from './chat/context/chatIntentAPIClient' import { ACCOUNT_LIMITS_INFO_URL, @@ -859,7 +860,9 @@ function registerChat( ) chatsController.registerViewsAndCommands() const promptsManager = new PromptsManager({ chatsController }) - disposables.push(new CodeActionProvider(), promptsManager) + const sourcegraphRemoteFileProvider = new SourcegraphRemoteFileProvider() + + disposables.push(new CodeActionProvider(), promptsManager, sourcegraphRemoteFileProvider) // Register a serializer for reviving the chat panel on reload if (vscode.window.registerWebviewPanelSerializer) { diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-completion-29-accepted.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-completion-29-accepted.png new file mode 100644 index 000000000000..000c78d9ac76 Binary files /dev/null and b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-completion-29-accepted.png differ diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-completion-29.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-completion-29-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-completion-29.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-completion-29-suggested.png diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-insertion-30-accepted.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-insertion-30-accepted.png new file mode 100644 index 000000000000..f3df301274bd Binary files /dev/null and b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-insertion-30-accepted.png differ diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-insertion-30.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-insertion-30-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-insertion-30.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-insertion-30-suggested.png diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-different-lines-44-accepted.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-different-lines-44-accepted.png new file mode 100644 index 000000000000..cc04328ff59a Binary files /dev/null and b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-different-lines-44-accepted.png differ diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-different-lines-44.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-different-lines-44-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-different-lines-44.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-different-lines-44-suggested.png diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-same-line-78-accepted.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-same-line-78-accepted.png new file mode 100644 index 000000000000..265e0120d472 Binary files /dev/null and b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-same-line-78-accepted.png differ diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-same-line-78.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-same-line-78-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-same-line-78.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-inline-decoration-multiple-insertions-same-line-78-suggested.png diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-70.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-70-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-70.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-70-suggested.png diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-76-accepted.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-76-accepted.png new file mode 100644 index 000000000000..2bb83f3b191e Binary files /dev/null and b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-76-accepted.png differ diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-76.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-76-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-76.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-76-suggested.png diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-end-of-file-38-accepted.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-end-of-file-38-accepted.png new file mode 100644 index 000000000000..7b5d0787a06c Binary files /dev/null and b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-end-of-file-38-accepted.png differ diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-end-of-file-38.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-end-of-file-38-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-end-of-file-38.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-end-of-file-38-suggested.png diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-tab-indentation-23-accepted.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-tab-indentation-23-accepted.png new file mode 100644 index 000000000000..99b3477f32d8 Binary files /dev/null and b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-tab-indentation-23-accepted.png differ diff --git a/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-tab-indentation-23.png b/vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-tab-indentation-23-suggested.png similarity index 100% rename from vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-tab-indentation-23.png rename to vscode/test/e2e/__snapshots__/auto-edits.test.ts/macos/autoedits-suffix-decoration-tab-indentation-23-suggested.png diff --git a/vscode/test/e2e/auto-edits.test.ts b/vscode/test/e2e/auto-edits.test.ts index cf5889389b5b..009397cd7f4b 100644 --- a/vscode/test/e2e/auto-edits.test.ts +++ b/vscode/test/e2e/auto-edits.test.ts @@ -1,10 +1,11 @@ import { expect } from '@playwright/test' -import type { Frame, Page } from '@playwright/test' +import type { Frame, Page, PageAssertionsToHaveScreenshotOptions } from '@playwright/test' import { CodyAutoSuggestionMode } from '@sourcegraph/cody-shared' import * as mockServer from '../fixtures/mock-server' import { sidebarExplorer, sidebarSignin } from './common' import { type DotcomUrlOverride, + type ExpectedV2Events, type ExtraWorkspaceSettings, test as baseTest, executeCommandInPalette, @@ -76,19 +77,13 @@ if (process.platform !== 'darwin') { test.skip() } -interface clipArgs { - x: number - y: number - width: number - height: number -} - interface LineOptions { /* The line in which the autoedit should be triggered */ line: number /* The column in which the autoedit should be triggered. Defaults to the end of the line */ column?: number - clip?: clipArgs + /* Whether the autoedit should be accepted once triggered. Defaults to true */ + shouldAccept?: boolean } interface AutoeditsTestOptions { @@ -99,6 +94,16 @@ interface AutoeditsTestOptions { lineOptions: LineOptions[] } +const SNAPSHOT_ASSERTIONS: PageAssertionsToHaveScreenshotOptions = { + // Note: Theses values allow some a small amount of variation in the diff view + // Be mindful of this when adding new tests that have minor differences + maxDiffPixelRatio: 0.02, + maxDiffPixels: 500, + // Threshold accounts for color changes between screenshots. It's important to keep this low as + // our decoration logic heavily relies on pure color changes to be functional + threshold: 0.01, +} + const autoeditsTestHelper = async ({ page, sidebar, @@ -141,8 +146,8 @@ const autoeditsTestHelper = async ({ await executeCommandInPalette(page, 'View: Toggle Zen Mode') await executeCommandInPalette(page, 'Hide Custom Title Bar In Full Screen') - for (const { line, column = Number.MAX_SAFE_INTEGER, clip } of lineOptions) { - const snapshotName = `${testCaseName}-${line}.png` + for (const { line, column = Number.MAX_SAFE_INTEGER, shouldAccept: accept = true } of lineOptions) { + const snapshotName = `${testCaseName}-${line}` await executeCommandInPalette(page, 'Go to Line/Column') await page.keyboard.type(`${line}:${column}`) await page.keyboard.press('Enter') @@ -152,87 +157,105 @@ const autoeditsTestHelper = async ({ // Wait for the diff view to stabilize - required to reduce flakiness await page.waitForTimeout(500) - await expect(page).toHaveScreenshot([snapshotPlatform, snapshotName], { - // Note: This values allow some a small amount of variation in the diff view - // Be mindful of this when adding new tests that have minor differences - maxDiffPixelRatio: 0.02, - maxDiffPixels: 500, - // Threshold accounts for color changes between screenshots. It's important to keep this low as - // our decoration logic heavily relies on pure color changes to be functional - threshold: 0.01, - clip, - }) + await expect(page).toHaveScreenshot( + [snapshotPlatform, `${snapshotName}-suggested.png`], + SNAPSHOT_ASSERTIONS + ) + + if (accept) { + // Trigger Tab to accept the autoedit + await page.keyboard.press('Tab') + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot( + [snapshotPlatform, `${snapshotName}-accepted.png`], + SNAPSHOT_ASSERTIONS + ) + + // Undo the change + await page.keyboard.press('ControlOrMeta+Z') + } } } -test('autoedits: triggers a multi-line diff view when edit affects existing lines', async ({ - page, - sidebar, -}) => { - const lineOptions: LineOptions[] = [{ line: 70 }, { line: 76 }] - await autoeditsTestHelper({ - page, - sidebar, - fileName: 'suffix-decoration-example-1.py', - testCaseName: 'autoedits-suffix-decoration', - lineOptions, - }) -}) -test('autoedits: triggers an inline completion when edit is an insertion immediately after the cursor', async ({ - page, - sidebar, -}) => { - const lineOptions: LineOptions[] = [{ line: 29 }] - await autoeditsTestHelper({ - page, - sidebar, - fileName: 'inline-completion-example-1.js', - testCaseName: 'autoedits-inline-completion', - lineOptions, - }) -}) +test.extend({ + expectedV2Events: ['cody.autoedit:suggested', 'cody.autoedit:suggested', 'cody.autoedit:accepted'], +})( + 'autoedits: triggers a multi-line diff view when edit affects existing lines', + async ({ page, sidebar }) => { + const lineOptions: LineOptions[] = [{ line: 70, shouldAccept: false }, { line: 76 }] + await autoeditsTestHelper({ + page, + sidebar, + fileName: 'suffix-decoration-example-1.py', + testCaseName: 'autoedits-suffix-decoration', + lineOptions, + }) + } +) -test('autoedits: triggers an inline decoration when an inline completion is desired, but the insertion position is before the cursor position', async ({ - page, - sidebar, -}) => { - const lineOptions: LineOptions[] = [{ line: 30 }] - await autoeditsTestHelper({ - page, - sidebar, - fileName: 'inline-completion-example-1.js', - testCaseName: 'autoedits-inline-decoration-insertion', - lineOptions, - }) -}) +test.extend({ + expectedV2Events: ['cody.autoedit:suggested', 'cody.autoedit:accepted'], +})( + 'autoedits: triggers an inline completion when edit is an insertion immediately after the cursor', + async ({ page, sidebar }) => { + const lineOptions: LineOptions[] = [{ line: 29 }] + await autoeditsTestHelper({ + page, + sidebar, + fileName: 'inline-completion-example-1.js', + testCaseName: 'autoedits-inline-completion', + lineOptions, + }) + } +) -test('autoedits: triggers inline decorations when multiple insertions are required on different lines', async ({ - page, - sidebar, -}) => { - const lineOptions: LineOptions[] = [{ line: 44 }] - await autoeditsTestHelper({ - page, - sidebar, - fileName: 'inline-decoration-example-1.rs', - testCaseName: 'autoedits-inline-decoration-multiple-insertions-different-lines', - lineOptions, - }) -}) +test.extend({ + expectedV2Events: ['cody.autoedit:suggested', 'cody.autoedit:accepted'], +})( + 'autoedits: triggers an inline decoration when an inline completion is desired, but the insertion position is before the cursor position', + async ({ page, sidebar }) => { + const lineOptions: LineOptions[] = [{ line: 30 }] + await autoeditsTestHelper({ + page, + sidebar, + fileName: 'inline-completion-example-1.js', + testCaseName: 'autoedits-inline-decoration-insertion', + lineOptions, + }) + } +) -test('autoedits: triggers inline decorations when multiple separate insertions are required on the same line', async ({ - page, - sidebar, -}) => { - const lineOptions: LineOptions[] = [{ line: 78 }] - await autoeditsTestHelper({ - page, - sidebar, - fileName: 'inline-decoration-example-2.ts', - testCaseName: 'autoedits-inline-decoration-multiple-insertions-same-line', - lineOptions, - }) -}) +test.extend({ + expectedV2Events: ['cody.autoedit:suggested', 'cody.autoedit:accepted'], +})( + 'autoedits: triggers inline decorations when multiple insertions are required on different lines', + async ({ page, sidebar }) => { + const lineOptions: LineOptions[] = [{ line: 44 }] + await autoeditsTestHelper({ + page, + sidebar, + fileName: 'inline-decoration-example-1.rs', + testCaseName: 'autoedits-inline-decoration-multiple-insertions-different-lines', + lineOptions, + }) + } +) + +test.extend({ + expectedV2Events: ['cody.autoedit:suggested', 'cody.autoedit:accepted'], +})( + 'autoedits: triggers inline decorations when multiple separate insertions are required on the same line', + async ({ page, sidebar }) => { + const lineOptions: LineOptions[] = [{ line: 78 }] + await autoeditsTestHelper({ + page, + sidebar, + fileName: 'inline-decoration-example-2.ts', + testCaseName: 'autoedits-inline-decoration-multiple-insertions-same-line', + lineOptions, + }) + } +) test('autoedits: triggers a suffix decoration and renders correctly in files that use tab based indentation', async ({ page, @@ -252,7 +275,7 @@ test('autoedits: does not show any suggestion if the suffix decoration spans fur page, sidebar, }) => { - const lineOptions: LineOptions[] = [{ line: 38 }] + const lineOptions: LineOptions[] = [{ line: 38, shouldAccept: false }] await autoeditsTestHelper({ page, sidebar, @@ -260,4 +283,14 @@ test('autoedits: does not show any suggestion if the suffix decoration spans fur testCaseName: 'autoedits-suffix-decoration-end-of-file', lineOptions, }) + + // Confirm that no telemetry events were fired, as no suggestion was shown + const suggestedEvent = mockServer.loggedV2Events.find( + event => event.testId === 'cody.autoedit:suggested' + ) + const acceptedEvent = mockServer.loggedV2Events.find( + event => event.testId === 'cody.autoedit:accepted' + ) + expect(suggestedEvent).toBeUndefined() + expect(acceptedEvent).toBeUndefined() }) diff --git a/vscode/webviews/chat/Transcript.tsx b/vscode/webviews/chat/Transcript.tsx index 129362235f90..ddf99dcb3477 100644 --- a/vscode/webviews/chat/Transcript.tsx +++ b/vscode/webviews/chat/Transcript.tsx @@ -249,6 +249,12 @@ interface TranscriptInteractionProps }) => void } +interface IntentResults { + query: string + intent: ChatMessage['intent'] + allScores?: { intent: string; score: number }[] +} + const TranscriptInteraction: FC = memo(props => { const { interaction: { humanMessage, assistantMessage }, @@ -268,14 +274,7 @@ const TranscriptInteraction: FC = memo(props => { smartApplyEnabled, editorRef: parentEditorRef, } = props - const [intentResults, setIntentResults] = useMutatedValue< - | { - intent: ChatMessage['intent'] - allScores?: { intent: string; score: number }[] - } - | undefined - | null - >() + const [intentResults, setIntentResults] = useMutatedValue() const { activeChatContext, setActiveChatContext } = props const humanEditorRef = useRef(null) @@ -311,10 +310,14 @@ const TranscriptInteraction: FC = memo(props => { return } + const { intent, intentScores } = intentFromSubmit + ? { intent: intentFromSubmit, intentScores: undefined } + : getIntentProps(editorValue, intentResults.current) + const commonProps = { editorValue, - intent: intentFromSubmit || intentResults.current?.intent, - intentScores: intentFromSubmit ? undefined : intentResults.current?.allScores, + intent, + intentScores, manuallySelectedIntent: !!intentFromSubmit, traceparent, } @@ -373,18 +376,16 @@ const TranscriptInteraction: FC = memo(props => { setIntentResults(undefined) - const subscription = extensionAPI - .detectIntent( - inputTextWithMappedContextChipsFromPromptEditorState(editorValue.editorState) - ) - .subscribe({ - next: value => { - setIntentResults(value) - }, - error: error => { - console.error('Error detecting intent:', error) - }, - }) + const query = inputTextWithMappedContextChipsFromPromptEditorState(editorValue.editorState) + + const subscription = extensionAPI.detectIntent(query).subscribe({ + next: value => { + setIntentResults(value && { ...value, query }) + }, + error: error => { + console.error('Error detecting intent:', error) + }, + }) // Clean up subscription if component unmounts return () => subscription.unsubscribe() @@ -792,3 +793,13 @@ function reevaluateSearchWithSelectedFilters({ selectedFilters, }) } + +const getIntentProps = (editorValue: SerializedPromptEditorValue, results?: IntentResults | null) => { + const query = inputTextWithMappedContextChipsFromPromptEditorState(editorValue.editorState) + + if (query === results?.query) { + return { intent: results.intent, intentScores: results.allScores } + } + + return {} +} diff --git a/vscode/webviews/components/codeSnippet/CodeSnippet.tsx b/vscode/webviews/components/codeSnippet/CodeSnippet.tsx index 9a021948a8ab..8f4393b14e76 100644 --- a/vscode/webviews/components/codeSnippet/CodeSnippet.tsx +++ b/vscode/webviews/components/codeSnippet/CodeSnippet.tsx @@ -24,12 +24,16 @@ import { pluralize, } from './utils' +import { CodyIDE } from '@sourcegraph/cody-shared' import type { NLSSearchFileMatch, NLSSearchResult, } from '@sourcegraph/cody-shared/src/sourcegraph-api/graphql/client' import type { Observable } from 'observable-fns' import { useInView } from 'react-intersection-observer' +import { URI } from 'vscode-uri' +import { getVSCodeAPI } from '../../utils/VSCodeApi' +import { useConfig } from '../../utils/useConfig' import styles from './CodeSnippet.module.css' const DEFAULT_VISIBILITY_OFFSET = '500px' @@ -123,6 +127,29 @@ export const FileMatchSearchResult: FC collapsedHighlightCount useEffect(() => setExpanded(allExpanded || defaultExpanded), [allExpanded, defaultExpanded]) + const { + clientCapabilities: { agentIDE }, + } = useConfig() + const openRemoteFile = useCallback( + (line?: number) => { + const urlWithLineNumber = line ? `${fileURL}?L${line}` : fileURL + if (agentIDE !== CodyIDE.VSCode) { + getVSCodeAPI().postMessage({ + command: 'links', + value: urlWithLineNumber, + }) + + return + } + + const uri = URI.parse(urlWithLineNumber) + getVSCodeAPI().postMessage({ + command: 'openRemoteFile', + uri, + }) + }, + [fileURL, agentIDE] + ) const handleVisibility = useCallback( (inView: boolean, entry: IntersectionObserverEntry) => { @@ -183,6 +210,7 @@ export const FileMatchSearchResult: FC {expandable && (