From bf241e0f678fed8567363be20b4155eb4c011f75 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Mon, 7 Jul 2025 22:19:54 +0300 Subject: [PATCH 01/27] feat(context): Display branch name in mention menu help text This commit enhances the mention menu to display the branch name associated with the context item, improving user clarity. - Adds a `getBranchHelpText` function to determine the appropriate help text based on available branch information. - The function checks if the branch is specified in the mention data or the query string. - Updates the MentionMenu component to use the `getBranchHelpText` function to display the branch name. - Adds unit tests for the `getBranchHelpText` function and `extractRepoAndBranch` function to ensure correct behavior. - Modifies `extractRepoAndBranch` to support parsing repo and branch from strings like "repo@branch", "repo", or "repo:directory@branch". - Updates the query to include the branch when searching for directories. --- .../mentionMenu/MentionMenu.branch.test.ts | 161 ++++++++++++++++++ .../src/mentions/mentionMenu/MentionMenu.tsx | 31 +++- .../openctx/remoteDirectorySearch.test.ts | 69 ++++++++ .../context/openctx/remoteDirectorySearch.ts | 33 +++- 4 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts create mode 100644 vscode/src/context/openctx/remoteDirectorySearch.test.ts diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts new file mode 100644 index 000000000000..841a44234c4b --- /dev/null +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts @@ -0,0 +1,161 @@ +import { ContextItemSource, REMOTE_DIRECTORY_PROVIDER_URI } from '@sourcegraph/cody-shared' +import type { MentionMenuData } from '@sourcegraph/cody-shared' +import { describe, expect, test } from 'vitest' +import { URI } from 'vscode-uri' + +// This would be imported from the MentionMenu component if it were exported +// For now, we'll create a test version of the function +function getBranchHelpText( + items: NonNullable, + mentionQuery: { text: string } +): string { + // Check if we have branch information from the current search + const firstItem = items[0] + if (firstItem?.type === 'openctx') { + const openCtxItem = firstItem as any // Simplified for testing + if (openCtxItem.mention?.data?.branch) { + return `* Sourced from the '${openCtxItem.mention.data.branch}' branch` + } + } + + // Check if user has specified a branch in the query + if (mentionQuery.text.includes('@')) { + const branchPart = mentionQuery.text.split('@')[1] + if (branchPart) { + // Remove anything after colon (directory path) + const branchName = branchPart.split(':')[0] + return `* Sourced from the '${branchName}' branch` + } + } + + return '* Sourced from the remote default branch' +} + +describe('MentionMenu branch selection', () => { + test('should show default branch text when no branch is specified', () => { + const items: MentionMenuData['items'] = [ + { + type: 'openctx', + provider: 'openctx', + title: 'src/components', + uri: URI.parse('https://example.com/repo/-/tree/src/components'), + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + source: ContextItemSource.User, + mention: { + uri: 'https://example.com/repo/-/tree/src/components', + data: { + repoName: 'test-repo', + directoryPath: 'src/components', + }, + }, + }, + ] + + const mentionQuery = { text: 'test-repo:src' } + + const result = getBranchHelpText(items!, mentionQuery) + expect(result).toBe('* Sourced from the remote default branch') + }) + + test('should show branch name when branch is specified in query', () => { + const items: MentionMenuData['items'] = [ + { + type: 'openctx', + provider: 'openctx', + title: 'src/components', + uri: URI.parse('https://example.com/repo/-/tree/src/components'), + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + source: ContextItemSource.User, + mention: { + uri: 'https://example.com/repo/-/tree/src/components', + data: { + repoName: 'test-repo', + directoryPath: 'src/components', + }, + }, + }, + ] + + const mentionQuery = { text: 'test-repo@feature-branch:src' } + + const result = getBranchHelpText(items!, mentionQuery) + expect(result).toBe("* Sourced from the 'feature-branch' branch") + }) + + test('should show branch name from mention data when available', () => { + const items: MentionMenuData['items'] = [ + { + type: 'openctx', + provider: 'openctx', + title: 'src/components', + uri: URI.parse('https://example.com/repo/-/tree/src/components'), + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + source: ContextItemSource.User, + mention: { + uri: 'https://example.com/repo/-/tree/src/components', + data: { + repoName: 'test-repo', + directoryPath: 'src/components', + branch: 'main', + }, + }, + }, + ] + + const mentionQuery = { text: 'test-repo:src' } + + const result = getBranchHelpText(items!, mentionQuery) + expect(result).toBe("* Sourced from the 'main' branch") + }) + + test('should prefer mention data branch over query branch', () => { + const items: MentionMenuData['items'] = [ + { + type: 'openctx', + provider: 'openctx', + title: 'src/components', + uri: URI.parse('https://example.com/repo/-/tree/src/components'), + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + source: ContextItemSource.User, + mention: { + uri: 'https://example.com/repo/-/tree/src/components', + data: { + repoName: 'test-repo', + directoryPath: 'src/components', + branch: 'actual-branch', + }, + }, + }, + ] + + const mentionQuery = { text: 'test-repo@query-branch:src' } + + const result = getBranchHelpText(items!, mentionQuery) + expect(result).toBe("* Sourced from the 'actual-branch' branch") + }) + + test('should handle empty items array', () => { + const items: MentionMenuData['items'] = [] + const mentionQuery = { text: 'test-repo@main:src' } + + const result = getBranchHelpText(items!, mentionQuery) + expect(result).toBe("* Sourced from the 'main' branch") + }) + + test('should handle non-openctx items', () => { + const items: MentionMenuData['items'] = [ + { + type: 'file', + provider: 'file', + title: 'test.ts', + uri: URI.parse('file:///test.ts'), + source: ContextItemSource.User, + }, + ] + + const mentionQuery = { text: 'test-repo@feature:src' } + + const result = getBranchHelpText(items!, mentionQuery) + expect(result).toBe("* Sourced from the 'feature' branch") + }) +}) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx index f2a4978d975c..8e4220069a12 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx @@ -305,7 +305,7 @@ export const MentionMenu: FunctionComponent< 'tw-bg-accent' )} > - * Sourced from the remote default branch + {getBranchHelpText(data.items, mentionQuery)} )} @@ -341,6 +341,35 @@ function commandRowValue( return contextItemID(row) } +function getBranchHelpText( + items: NonNullable, + mentionQuery: MentionQuery +): string { + // Check if we have branch information from the current search + const firstItem = items[0] + if (firstItem?.type === 'openctx') { + const openCtxItem = firstItem as ContextItem & { + type: 'openctx' + mention?: { data?: { branch?: string } } + } + if (openCtxItem.mention?.data?.branch) { + return `* Sourced from the '${openCtxItem.mention.data.branch}' branch` + } + } + + // Check if user has specified a branch in the query + if (mentionQuery.text.includes('@')) { + const branchPart = mentionQuery.text.split('@')[1] + if (branchPart) { + // Remove anything after colon (directory path) + const branchName = branchPart.split(':')[0] + return `* Sourced from the '${branchName}' branch` + } + } + + return '* Sourced from the remote default branch' +} + function getEmptyLabel( parentItem: ContextMentionProviderMetadata | null, mentionQuery: MentionQuery diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts new file mode 100644 index 000000000000..e300d19db2ee --- /dev/null +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest' + +// Test the extractRepoAndBranch function logic +function extractRepoAndBranch(input: string): [string, string | undefined] { + // Handle case where input contains a colon (repo:directory@branch) + const colonIndex = input.indexOf(':') + if (colonIndex !== -1) { + const repoPart = input.substring(0, colonIndex) + const atIndex = repoPart.indexOf('@') + if (atIndex !== -1) { + return [repoPart.substring(0, atIndex), repoPart.substring(atIndex + 1)] + } + return [repoPart, undefined] + } + + // Handle simple case: repo@branch or repo + const atIndex = input.indexOf('@') + if (atIndex !== -1) { + return [input.substring(0, atIndex), input.substring(atIndex + 1)] + } + + return [input, undefined] +} + +describe('RemoteDirectoryProvider branch parsing', () => { + describe('extractRepoAndBranch', () => { + test('should extract repo name without branch', () => { + const [repo, branch] = extractRepoAndBranch('test-repo') + expect(repo).toBe('test-repo') + expect(branch).toBeUndefined() + }) + + test('should extract repo name with branch', () => { + const [repo, branch] = extractRepoAndBranch('test-repo@feature-branch') + expect(repo).toBe('test-repo') + expect(branch).toBe('feature-branch') + }) + + test('should handle repo:directory format without branch', () => { + const [repo, branch] = extractRepoAndBranch('test-repo:src/components') + expect(repo).toBe('test-repo') + expect(branch).toBeUndefined() + }) + + test('should handle repo@branch:directory format', () => { + const [repo, branch] = extractRepoAndBranch('test-repo@dev:src/components') + expect(repo).toBe('test-repo') + expect(branch).toBe('dev') + }) + + test('should handle complex branch names', () => { + const [repo, branch] = extractRepoAndBranch('my-repo@feature/fix-123') + expect(repo).toBe('my-repo') + expect(branch).toBe('feature/fix-123') + }) + + test('should handle empty string', () => { + const [repo, branch] = extractRepoAndBranch('') + expect(repo).toBe('') + expect(branch).toBeUndefined() + }) + + test('should handle @ at the end', () => { + const [repo, branch] = extractRepoAndBranch('test-repo@') + expect(repo).toBe('test-repo') + expect(branch).toBe('') + }) + }) +}) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 588df00eb4f4..199db2c64ade 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -7,6 +7,31 @@ import { escapeRegExp } from './remoteFileSearch' import type { OpenCtxProvider } from './types' +/** + * Extracts repo name and optional branch from a string. + * Supports formats: "repo@branch", "repo", or "repo:directory@branch" + */ +function extractRepoAndBranch(input: string): [string, string | undefined] { + // Handle case where input contains a colon (repo:directory@branch) + const colonIndex = input.indexOf(':') + if (colonIndex !== -1) { + const repoPart = input.substring(0, colonIndex) + const atIndex = repoPart.indexOf('@') + if (atIndex !== -1) { + return [repoPart.substring(0, atIndex), repoPart.substring(atIndex + 1)] + } + return [repoPart, undefined] + } + + // Handle simple case: repo@branch or repo + const atIndex = input.indexOf('@') + if (atIndex !== -1) { + return [input.substring(0, atIndex), input.substring(atIndex + 1)] + } + + return [input, undefined] +} + const RemoteDirectoryProvider = createRemoteDirectoryProvider() export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProvider { @@ -45,9 +70,12 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv } async function getDirectoryMentions(repoName: string, directoryPath?: string): Promise { - const repoRe = `^${escapeRegExp(repoName)}$` + // Parse repo name and optional branch (format: repo@branch or repo:directory@branch) + const [repoNamePart, branchPart] = extractRepoAndBranch(repoName) + const repoRe = `^${escapeRegExp(repoNamePart)}$` const directoryRe = directoryPath ? escapeRegExp(directoryPath) : '' - const query = `repo:${repoRe} file:${directoryRe}.*\/.* select:file.directory count:10` + const repoWithBranch = branchPart ? `${repoRe}@${branchPart}` : repoRe + const query = `repo:${repoWithBranch} file:${directoryRe}.*\/.* select:file.directory count:10` const { auth: { serverEndpoint }, @@ -75,6 +103,7 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P repoID: result.repository.id, rev: result.file.commit.oid, directoryPath: result.file.path, + branch: branchPart, }, } satisfies Mention }) From a71078a42e428e5aaf157fa3c1bec50ec548b460 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Tue, 8 Jul 2025 23:14:30 +0300 Subject: [PATCH 02/27] feat(context): Enhance remote directory search and item retrieval This commit introduces several enhancements to the remote directory search functionality, improving the accuracy and usability of directory context within the Cody extension. - Improves the parsing of repo and branch from the query string in `extractRepoAndBranch` function. - Adds tests for `github.com/mrdoob/three.js@dev` parsing. - Implements branch selection for root directory search. - Implements branch selection with directory path filtering. - Introduces `getDirectoryContents` query to fetch directory contents. - Updates `getDirectoryItem` to use `getDirectoryContents` and filter directory entries. - Adds DIRECTORY_CONTENTS_QUERY to GraphQL queries. - Adds `getDirectoryContents` method to `SourcegraphGraphQLAPIClient`. --- .../src/sourcegraph-api/graphql/client.ts | 33 +++ .../src/sourcegraph-api/graphql/queries.ts | 24 +++ .../openctx/remoteDirectorySearch.test.ts | 204 +++++++++++++++++- .../context/openctx/remoteDirectorySearch.ts | 68 ++++-- 4 files changed, 308 insertions(+), 21 deletions(-) diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index 9294295ece64..8c1e1653818e 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -42,6 +42,7 @@ import { CURRENT_USER_INFO_QUERY, CURRENT_USER_ROLE_QUERY, DELETE_ACCESS_TOKEN_MUTATION, + DIRECTORY_CONTENTS_QUERY, EVALUATE_FEATURE_FLAGS_QUERY, EVALUATE_FEATURE_FLAG_QUERY, FILE_CONTENTS_QUERY, @@ -370,6 +371,23 @@ interface FileContentsResponse { } | null } +interface DirectoryContentsResponse { + repository: { + commit: { + tree: { + entries: Array<{ + name: string + path: string + byteSize?: number + url: string + content?: string + isDirectory?: boolean + }> + } | null + } | null + } | null +} + export interface RepositoryIdResponse { repository: { id: string } | null } @@ -1049,6 +1067,21 @@ export class SourcegraphGraphQLAPIClient { }).then(response => extractDataOrError(response, data => data)) } + public async getDirectoryContents( + repoName: string, + path: string, + revision = 'HEAD' + ): Promise { + return this.fetchSourcegraphAPI>( + DIRECTORY_CONTENTS_QUERY, + { + repoName, + path, + revision, + } + ).then(response => extractDataOrError(response, data => data)) + } + public async getRepoId(repoName: string): Promise { return this.fetchSourcegraphAPI>(REPOSITORY_ID_QUERY, { name: repoName, diff --git a/lib/shared/src/sourcegraph-api/graphql/queries.ts b/lib/shared/src/sourcegraph-api/graphql/queries.ts index eacd69edff15..3e925754ac24 100644 --- a/lib/shared/src/sourcegraph-api/graphql/queries.ts +++ b/lib/shared/src/sourcegraph-api/graphql/queries.ts @@ -754,3 +754,27 @@ export const NLS_SEARCH_QUERY = ` } } }` + +export const DIRECTORY_CONTENTS_QUERY = ` +query GetDirectoryContents($repoName: String!, $revision: String!, $path: String!) { + repository(name: $repoName) { + commit(rev: $revision) { + tree(path: $path) { + entries(first: 50) { + ... on GitBlob { + name + path + byteSize + url + content + } + ... on GitTree { + name + path + isDirectory + } + } + } + } + } +}` diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index e300d19db2ee..5724ce571629 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test } from 'vitest' +import { graphqlClient, mockResolvedConfig } from '@sourcegraph/cody-shared' +import { describe, expect, test, vi } from 'vitest' +import { createRemoteDirectoryProvider } from './remoteDirectorySearch' // Test the extractRepoAndBranch function logic function extractRepoAndBranch(input: string): [string, string | undefined] { @@ -65,5 +67,205 @@ describe('RemoteDirectoryProvider branch parsing', () => { expect(repo).toBe('test-repo') expect(branch).toBe('') }) + + test('should extract github.com/mrdoob/three.js@dev correctly', () => { + const [repo, branch] = extractRepoAndBranch('github.com/mrdoob/three.js@dev') + expect(repo).toBe('github.com/mrdoob/three.js') + expect(branch).toBe('dev') + }) + }) +}) + +describe('RemoteDirectoryProvider mentions', () => { + test('should handle branch selection for root directory search', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: 'https://demo.sourcegraph.com', + }, + }) + + // Mock the graphqlClient.searchFileMatches method + const mockSearchFileMatches = { + search: { + results: { + results: [ + { + __typename: 'FileMatch', + repository: { + id: 'repo-id', + name: 'github.com/mrdoob/three.js', + }, + file: { + url: '/github.com/mrdoob/three.js@dev/-/tree/docs', + path: 'docs', + commit: { + oid: 'abc123', + }, + }, + }, + ], + }, + }, + } + + vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.({ query: 'github.com/mrdoob/three.js@dev' }, {}) + + expect(mentions).toHaveLength(1) + + expect(mentions?.[0]).toEqual({ + uri: 'https://demo.sourcegraph.com/github.com/mrdoob/three.js@dev/-/tree/docs', + title: 'docs', + description: ' ', + data: { + repoName: 'github.com/mrdoob/three.js', + repoID: 'repo-id', + rev: 'abc123', + directoryPath: 'docs', + // branch: 'dev', // TODO: Fix branch extraction + }, + }) + }) + + test('should handle branch selection with directory path filtering', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: 'https://demo.sourcegraph.com', + }, + }) + + // Mock the graphqlClient.searchFileMatches method + const mockSearchFileMatches = { + search: { + results: { + results: [ + { + __typename: 'FileMatch', + repository: { + id: 'repo-id', + name: 'github.com/mrdoob/three.js', + }, + file: { + url: '/github.com/mrdoob/three.js@e2e/-/tree/manual/en', + path: 'manual/en', + commit: { + oid: 'abc123', + }, + }, + }, + ], + }, + }, + } + + vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.( + { query: 'github.com/mrdoob/three.js@e2e/manual' }, + {} + ) + + expect(mentions).toHaveLength(1) + expect(mentions?.[0]).toEqual({ + uri: 'https://demo.sourcegraph.com/github.com/mrdoob/three.js@e2e/-/tree/manual/en', + title: 'manual/en', + description: ' ', + data: { + repoName: 'github.com/mrdoob/three.js', + repoID: 'repo-id', + rev: 'abc123', + directoryPath: 'manual/en', + branch: 'e2e', + }, + }) + + // Verify the correct parameters were passed to searchFileMatches + expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( + 'repo:^github\\.com/mrdoob/three\\.js$@e2e file:manual.*\\/.* select:file.directory count:10' + ) + }) +}) + +describe('RemoteDirectoryProvider directory contents', () => { + test('should return directory contents as items', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: 'https://demo.sourcegraph.com', + }, + }) + + // Mock the graphqlClient.getDirectoryContents method + const mockDirectoryContents = { + repository: { + commit: { + tree: { + entries: [ + { + name: 'file1.ts', + path: 'src/file1.ts', + url: '/repo/-/blob/src/file1.ts', + content: 'const foo = "bar";', + byteSize: 18, + }, + { + name: 'file2.js', + path: 'src/file2.js', + url: '/repo/-/blob/src/file2.js', + content: 'console.log("hello");', + byteSize: 21, + }, + { + name: 'subdir', + path: 'src/subdir', + url: '/repo/-/tree/src/subdir', + isDirectory: true, + }, + ], + }, + }, + }, + } + + vi.spyOn(graphqlClient, 'getDirectoryContents').mockResolvedValue(mockDirectoryContents) + + const provider = createRemoteDirectoryProvider() + const items = await provider.items?.( + { + mention: { + uri: 'test-uri', + title: 'test-title', + description: 'test-description', + data: { + repoName: 'test-repo', + directoryPath: 'src', + rev: 'HEAD', + }, + }, + message: 'test query', + }, + {} + ) + + expect(items).toHaveLength(2) // Only files, not directories + expect(items?.[0]).toEqual({ + url: 'https://demo.sourcegraph.com/repo/-/blob/src/file1.ts', + title: 'src/file1.ts', + ai: { + content: 'const foo = "bar";', + }, + }) + expect(items?.[1]).toEqual({ + url: 'https://demo.sourcegraph.com/repo/-/blob/src/file2.js', + title: 'src/file2.js', + ai: { + content: 'console.log("hello");', + }, + }) }) }) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 199db2c64ade..40147fbd88ef 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -49,6 +49,25 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv const [repoName, directoryPath] = query?.split(':') || [] if (!query?.includes(':') || !repoName.trim()) { + // Check if the query contains branch specification (@branch) + if (query?.includes('@')) { + // Handle both @branch and @branch/directory formats + const trimmedQuery = query?.trim() ?? '' + const slashIndex = trimmedQuery.lastIndexOf('/') + + if (slashIndex > 0) { + // Format: repo@branch/directory + const repoWithBranch = trimmedQuery.substring(0, slashIndex) + const directoryPathPart = trimmedQuery.substring(slashIndex + 1) + return await getDirectoryMentions(repoWithBranch, directoryPathPart) + } + + // Format: repo@branch (root directory search) + const [repoNamePart] = extractRepoAndBranch(trimmedQuery) + if (repoNamePart.trim()) { + return await getDirectoryMentions(trimmedQuery, '') + } + } return await getRepositoryMentions(query?.trim() ?? '', REMOTE_DIRECTORY_PROVIDER_URI) } @@ -56,14 +75,15 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv }, async items({ mention, message }) { - if (!mention?.data?.repoID || !mention?.data?.directoryPath || !message) { + if (!mention?.data?.repoName || !mention?.data?.directoryPath || !message) { return [] } return await getDirectoryItem( message, - mention.data.repoID as string, - mention.data.directoryPath as string + mention.data.repoName as string, + mention.data.directoryPath as string, + mention.data.rev as string ) }, } @@ -75,7 +95,10 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P const repoRe = `^${escapeRegExp(repoNamePart)}$` const directoryRe = directoryPath ? escapeRegExp(directoryPath) : '' const repoWithBranch = branchPart ? `${repoRe}@${branchPart}` : repoRe - const query = `repo:${repoWithBranch} file:${directoryRe}.*\/.* select:file.directory count:10` + + // For root directory search, use a pattern that finds top-level directories + const filePattern = directoryPath ? `${directoryRe}.*\\/.*` : '[^/]+\\/.*' + const query = `repo:${repoWithBranch} file:${filePattern} select:file.directory count:10` const { auth: { serverEndpoint }, @@ -110,27 +133,32 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P .filter(isDefined) } -async function getDirectoryItem(query: string, repoID: string, directoryPath: string): Promise { - const dataOrError = await graphqlClient.contextSearch({ - repoIDs: [repoID], - query, - filePatterns: [`^${directoryPath}.*`], - }) +async function getDirectoryItem( + query: string, + repoName: string, + directoryPath: string, + revision?: string +): Promise { + const dataOrError = await graphqlClient.getDirectoryContents(repoName, directoryPath, revision) if (isError(dataOrError) || dataOrError === null) { return [] } - return dataOrError.map( - node => - ({ - url: node.uri.toString(), - title: node.path, - ai: { - content: node.content, - }, - }) as Item - ) + const entries = dataOrError.repository?.commit?.tree?.entries || [] + const { + auth: { serverEndpoint }, + } = await currentResolvedConfig() + + return entries + .filter(entry => entry.content && !entry.isDirectory) // Only include files with content + .map(entry => ({ + url: `${serverEndpoint.replace(/\/$/, '')}${entry.url}`, + title: entry.path, + ai: { + content: entry.content, + }, + })) as Item[] } export default RemoteDirectoryProvider From 064ccf80038511d1f32e5d1c387492368f119f3d Mon Sep 17 00:00:00 2001 From: David Ichim Date: Sun, 20 Jul 2025 10:45:29 +0300 Subject: [PATCH 03/27] feat(context): Improve remote directory item retrieval with context search This commit enhances the retrieval of remote directory items by utilizing context search for improved accuracy and performance. It also introduces a fallback mechanism to `getDirectoryContents` if context search returns no results. - Implements context search for retrieving directory items using `graphqlClient.contextSearch`. - Adds a fallback mechanism to `getDirectoryContents` if context search returns no results. - Modifies `getDirectoryItem` to accept `repoID` instead of `query`. - Updates `getDirectoryItem` to use directory basename as search query since contextSearch requires non-empty query. - Adds revision support to context search. - Adds repoName and revision to context item mentions. - Reduces the number of entries fetched in `GetDirectoryContents` query to 15. - Updates file pattern regex for root directory search in `getDirectoryMentions` to ensure it matches from the start of the string. - Constructs URL with branch information in `getDirectoryMentions` if available. - Fixes parsing of repo and branch from query string in `extractRepoAndBranch` function by using `indexOf` instead of `lastIndexOf` to find the `@` symbol. --- .../src/sourcegraph-api/graphql/client.ts | 3 + .../src/sourcegraph-api/graphql/queries.ts | 2 +- vscode/src/chat/context/chatContext.ts | 2 + .../context/openctx/remoteDirectorySearch.ts | 80 ++++++++++++++----- 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index 8c1e1653818e..aec7e6bbf7e0 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -1150,11 +1150,13 @@ export class SourcegraphGraphQLAPIClient { query, signal, filePatterns, + revision, }: { repoIDs: string[] query: string signal?: AbortSignal filePatterns?: string[] + revision?: string }): Promise { const hasContextMatchingSupport = await this.isValidSiteVersion({ minimumVersion: '5.8.0', @@ -1176,6 +1178,7 @@ export class SourcegraphGraphQLAPIClient { codeResultsCount: 15, textResultsCount: 5, ...(hasFilePathSupport ? { filePatterns } : {}), + ...(revision ? { revision } : {}), }, signal ).then(response => diff --git a/lib/shared/src/sourcegraph-api/graphql/queries.ts b/lib/shared/src/sourcegraph-api/graphql/queries.ts index 3e925754ac24..eb3a0be0cb18 100644 --- a/lib/shared/src/sourcegraph-api/graphql/queries.ts +++ b/lib/shared/src/sourcegraph-api/graphql/queries.ts @@ -760,7 +760,7 @@ query GetDirectoryContents($repoName: String!, $revision: String!, $path: String repository(name: $repoName) { commit(rev: $revision) { tree(path: $path) { - entries(first: 50) { + entries(first: 15) { ... on GitBlob { name path diff --git a/vscode/src/chat/context/chatContext.ts b/vscode/src/chat/context/chatContext.ts index 33716b920917..dfd1c12ccf39 100644 --- a/vscode/src/chat/context/chatContext.ts +++ b/vscode/src/chat/context/chatContext.ts @@ -386,6 +386,8 @@ export function contextItemMentionFromOpenCtxItem( title: item.title, providerUri: item.providerUri, provider: 'openctx', + repoName: item.data?.repoName as string | undefined, + revision: item.data?.branch as string | undefined, mention: { uri: item.uri, data: item.data, diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 40147fbd88ef..560aac70065f 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -53,7 +53,8 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv if (query?.includes('@')) { // Handle both @branch and @branch/directory formats const trimmedQuery = query?.trim() ?? '' - const slashIndex = trimmedQuery.lastIndexOf('/') + const atIndex = trimmedQuery.indexOf('@') + const slashIndex = atIndex >= 0 ? trimmedQuery.indexOf('/', atIndex) : -1 if (slashIndex > 0) { // Format: repo@branch/directory @@ -70,20 +71,21 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv } return await getRepositoryMentions(query?.trim() ?? '', REMOTE_DIRECTORY_PROVIDER_URI) } - return await getDirectoryMentions(repoName, directoryPath.trim()) }, async items({ mention, message }) { - if (!mention?.data?.repoName || !mention?.data?.directoryPath || !message) { + if (!mention?.data?.repoID || !mention?.data?.directoryPath || !message) { return [] } + const revision = mention.data.branch ?? mention.data.rev return await getDirectoryItem( message, + mention.data.repoID as string, mention.data.repoName as string, mention.data.directoryPath as string, - mention.data.rev as string + revision as string ) }, } @@ -97,7 +99,7 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P const repoWithBranch = branchPart ? `${repoRe}@${branchPart}` : repoRe // For root directory search, use a pattern that finds top-level directories - const filePattern = directoryPath ? `${directoryRe}.*\\/.*` : '[^/]+\\/.*' + const filePattern = directoryPath ? `^${directoryRe}.*\\/.*` : '[^/]+\\/.*' const query = `repo:${repoWithBranch} file:${filePattern} select:file.directory count:10` const { @@ -115,7 +117,10 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P return null } - const url = `${serverEndpoint.replace(/\/$/, '')}${result.file.url}` + // Construct URL with branch information if available + const baseUrl = `${serverEndpoint.replace(/\/$/, '')}/${result.repository.name}` + const branchUrl = branchPart ? `${baseUrl}@${branchPart}` : baseUrl + const url = `${branchUrl}/-/blob/${result.file.path}` return { uri: url, @@ -134,31 +139,70 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P } async function getDirectoryItem( - query: string, + _userMessage: string, // ignore content - we want all files in directory + repoID: string, repoName: string, directoryPath: string, revision?: string ): Promise { - const dataOrError = await graphqlClient.getDirectoryContents(repoName, directoryPath, revision) + const filePatterns = [`^${escapeRegExp(directoryPath)}.*`] + // Use directory basename as search query since contextSearch requires non-empty query + const searchQuery = directoryPath.split('/').pop() || '.' + const dataOrError = await graphqlClient.contextSearch({ + repoIDs: [repoID], + query: searchQuery, + filePatterns, + revision, + }) if (isError(dataOrError) || dataOrError === null) { return [] } - const entries = dataOrError.repository?.commit?.tree?.entries || [] + // If contextSearch returns no results, try fallback to getDirectoryContents + if (dataOrError.length === 0) { + const fallbackData = await graphqlClient.getDirectoryContents(repoName, directoryPath, revision) + if (!isError(fallbackData) && fallbackData !== null) { + const entries = fallbackData.repository?.commit?.tree?.entries || [] + const { + auth: { serverEndpoint }, + } = await currentResolvedConfig() + + return entries + .filter(entry => entry.content && !entry.isDirectory) // Only include files with content + .map(entry => ({ + url: revision + ? `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${revision}/-/blob/${ + entry.path + }` + : `${serverEndpoint.replace(/\/$/, '')}${entry.url}`, + title: entry.path, + ai: { + content: entry.content, + }, + })) as Item[] + } + return [] + } + const { auth: { serverEndpoint }, } = await currentResolvedConfig() - return entries - .filter(entry => entry.content && !entry.isDirectory) // Only include files with content - .map(entry => ({ - url: `${serverEndpoint.replace(/\/$/, '')}${entry.url}`, - title: entry.path, - ai: { - content: entry.content, - }, - })) as Item[] + return dataOrError.map( + node => + ({ + url: revision + ? `${serverEndpoint.replace(/\/$/, '')}/${node.repoName}@${revision}/-/blob/${ + node.path + }` + : node.uri.toString(), + title: node.path, + ai: { + content: node.content, + }, + }) as Item + ) } export default RemoteDirectoryProvider From 7e375b5b66831e1de82001d1f9c3b1c49fb82794 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Sun, 20 Jul 2025 12:42:37 +0300 Subject: [PATCH 04/27] fix(context): Improve remote directory search tests and URL generation This commit addresses issues in the remote directory search tests and improves URL generation for directory mentions. - Updates test assertions to use the correct server endpoint from the mock configuration. - Mocks `contextSearch` to return an empty array to trigger fallback in tests. - Adds `repoID` to the data object in directory contents tests. - Corrects the URL generation in `getDirectoryMentions` to use `/tree/` instead of `/blob/` for directory paths. - Updates the file pattern regex in `searchFileMatches` to ensure it matches from the start of the string. --- .../openctx/remoteDirectorySearch.test.ts | 35 +++++++++++++------ .../context/openctx/remoteDirectorySearch.ts | 2 +- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 5724ce571629..3dbe75af4f61 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -1,7 +1,17 @@ -import { graphqlClient, mockResolvedConfig } from '@sourcegraph/cody-shared' +import { + CLIENT_CAPABILITIES_FIXTURE, + graphqlClient, + mockClientCapabilities, + mockResolvedConfig +} from '@sourcegraph/cody-shared' import { describe, expect, test, vi } from 'vitest' import { createRemoteDirectoryProvider } from './remoteDirectorySearch' +// Mock client capabilities to avoid "clientCapabilities called before configuration was set" error +mockClientCapabilities(CLIENT_CAPABILITIES_FIXTURE) + +const auth = { serverEndpoint: 'https://sourcegraph.com' } + // Test the extractRepoAndBranch function logic function extractRepoAndBranch(input: string): [string, string | undefined] { // Handle case where input contains a colon (repo:directory@branch) @@ -81,7 +91,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Mock the resolved config mockResolvedConfig({ auth: { - serverEndpoint: 'https://demo.sourcegraph.com', + serverEndpoint: auth.serverEndpoint, }, }) @@ -117,15 +127,15 @@ describe('RemoteDirectoryProvider mentions', () => { expect(mentions).toHaveLength(1) expect(mentions?.[0]).toEqual({ - uri: 'https://demo.sourcegraph.com/github.com/mrdoob/three.js@dev/-/tree/docs', + uri: `${auth.serverEndpoint}/github.com/mrdoob/three.js@dev/-/tree/docs`, title: 'docs', description: ' ', data: { + branch: "dev", repoName: 'github.com/mrdoob/three.js', repoID: 'repo-id', rev: 'abc123', directoryPath: 'docs', - // branch: 'dev', // TODO: Fix branch extraction }, }) }) @@ -134,7 +144,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Mock the resolved config mockResolvedConfig({ auth: { - serverEndpoint: 'https://demo.sourcegraph.com', + serverEndpoint: auth.serverEndpoint, }, }) @@ -172,7 +182,7 @@ describe('RemoteDirectoryProvider mentions', () => { expect(mentions).toHaveLength(1) expect(mentions?.[0]).toEqual({ - uri: 'https://demo.sourcegraph.com/github.com/mrdoob/three.js@e2e/-/tree/manual/en', + uri: `${auth.serverEndpoint}/github.com/mrdoob/three.js@e2e/-/tree/manual/en`, title: 'manual/en', description: ' ', data: { @@ -186,7 +196,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Verify the correct parameters were passed to searchFileMatches expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - 'repo:^github\\.com/mrdoob/three\\.js$@e2e file:manual.*\\/.* select:file.directory count:10' + 'repo:^github\\.com/mrdoob/three\\.js$@e2e file:^manual.*\\/.* select:file.directory count:10' ) }) }) @@ -196,10 +206,14 @@ describe('RemoteDirectoryProvider directory contents', () => { // Mock the resolved config mockResolvedConfig({ auth: { - serverEndpoint: 'https://demo.sourcegraph.com', + serverEndpoint: auth.serverEndpoint, }, }) + + // Mock contextSearch to return empty array to trigger fallback + vi.spyOn(graphqlClient, 'contextSearch').mockResolvedValue([]) + // Mock the graphqlClient.getDirectoryContents method const mockDirectoryContents = { repository: { @@ -243,6 +257,7 @@ describe('RemoteDirectoryProvider directory contents', () => { description: 'test-description', data: { repoName: 'test-repo', + repoID: 'repo-id', directoryPath: 'src', rev: 'HEAD', }, @@ -254,14 +269,14 @@ describe('RemoteDirectoryProvider directory contents', () => { expect(items).toHaveLength(2) // Only files, not directories expect(items?.[0]).toEqual({ - url: 'https://demo.sourcegraph.com/repo/-/blob/src/file1.ts', + url: `${auth.serverEndpoint}/test-repo@HEAD/-/blob/src/file1.ts`, title: 'src/file1.ts', ai: { content: 'const foo = "bar";', }, }) expect(items?.[1]).toEqual({ - url: 'https://demo.sourcegraph.com/repo/-/blob/src/file2.js', + url: `${auth.serverEndpoint}/test-repo@HEAD/-/blob/src/file2.js`, title: 'src/file2.js', ai: { content: 'console.log("hello");', diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 560aac70065f..1e3168a6c096 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -120,7 +120,7 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P // Construct URL with branch information if available const baseUrl = `${serverEndpoint.replace(/\/$/, '')}/${result.repository.name}` const branchUrl = branchPart ? `${baseUrl}@${branchPart}` : baseUrl - const url = `${branchUrl}/-/blob/${result.file.path}` + const url = `${branchUrl}/-/tree/${result.file.path}` return { uri: url, From 76143f9d0fd237f274938a83ca098810ca137aa8 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Sun, 20 Jul 2025 23:18:26 +0300 Subject: [PATCH 05/27] feat(context): Enhance remote directory search with branch support and fuzzy matching This commit introduces several enhancements to the remote directory search functionality, including improved branch handling and fuzzy matching for branch names, enhancing the user experience when searching for directories within specific branches. - Implements branch suggestions when the user types `repo:` to show available branches. - Adds fuzzy matching for branch names when the user types `repo:@query` to filter branches. - Supports branch names with hyphens, slashes, and dots in `extractRepoAndBranch`. - Improves the display of branch information in the mention menu, including help text indicating the source branch. - Updates the mention menu to display the correct query label with branch information. - Fixes the search query to include the branch name when searching for directories. - Adds tests for branch name parsing and branch suggestions. --- .../mentionMenu/MentionMenu.branch.test.ts | 38 +- .../src/mentions/mentionMenu/MentionMenu.tsx | 41 ++- .../mentionMenu/MentionMenuItem.module.css | 5 +- .../src/sourcegraph-api/graphql/client.ts | 18 +- .../src/sourcegraph-api/graphql/queries.ts | 10 + .../openctx/remoteDirectorySearch.test.ts | 348 +++++++++++++++++- .../context/openctx/remoteDirectorySearch.ts | 83 ++++- 7 files changed, 519 insertions(+), 24 deletions(-) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts index 841a44234c4b..a0e243e72b8b 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts @@ -9,10 +9,20 @@ function getBranchHelpText( items: NonNullable, mentionQuery: { text: string } ): string { - // Check if we have branch information from the current search + // Check if we're in branch selection mode (showing branch options) const firstItem = items[0] if (firstItem?.type === 'openctx') { const openCtxItem = firstItem as any // Simplified for testing + + // If we're showing branch options (no directoryPath), show branch selection help + if (openCtxItem.mention?.data?.repoName && !openCtxItem.mention?.data?.directoryPath) { + // Check if this is a branch mention (title starts with @) + if (firstItem.title?.startsWith('@')) { + return '* @type to filter searches for a specific branch' + } + } + + // If we're browsing directories and have branch info, show current branch if (openCtxItem.mention?.data?.branch) { return `* Sourced from the '${openCtxItem.mention.data.branch}' branch` } @@ -158,4 +168,30 @@ describe('MentionMenu branch selection', () => { const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe("* Sourced from the 'feature' branch") }) + + test('should show branch selection help when showing branch options', () => { + const items: MentionMenuData['items'] = [ + { + type: 'openctx', + provider: 'openctx', + title: '@main', + uri: URI.parse('https://example.com/repo@main'), + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + source: ContextItemSource.User, + mention: { + uri: 'https://example.com/repo@main', + data: { + repoName: 'test-repo', + branch: 'main', + // No directoryPath - this indicates we're in branch selection mode + }, + }, + }, + ] + + const mentionQuery = { text: 'test-repo:' } + + const result = getBranchHelpText(items!, mentionQuery) + expect(result).toBe('* Select or @ search for a specific branch') + }) }) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx index 8e4220069a12..9d6f59ec827c 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx @@ -165,20 +165,24 @@ export const MentionMenu: FunctionComponent< // Rather keep the provider in place and update the query with repo name so that the provider can // start showing the files instead. + const branch = item.mention?.data?.branch + const repoName = item?.mention?.data.repoName + const repoWithBranch = branch ? `${repoName}@${branch}` : repoName + updateMentionMenuParams({ parentItem: item.providerUri === REMOTE_DIRECTORY_PROVIDER_URI ? { id: REMOTE_DIRECTORY_PROVIDER_URI, title: 'Remote Directories', - queryLabel: 'Enter directory path to search', - emptyLabel: `No matching directories found in ${item?.mention?.data.repoName} repository`, + queryLabel: `Enter directory path to search in ${repoWithBranch}`, + emptyLabel: `No matching directories found in ${repoWithBranch} repository`, } : { id: REMOTE_FILE_PROVIDER_URI, title: 'Remote Files', - queryLabel: 'Enter file path to search', - emptyLabel: `No matching files found in ${item?.mention?.data.repoName} repository`, + queryLabel: `Enter file path to search in ${repoWithBranch}`, + emptyLabel: `No matching files found in ${repoWithBranch} repository`, }, }) @@ -192,7 +196,11 @@ export const MentionMenu: FunctionComponent< const cursorPosition = selection.anchorOffset const mentionStart = cursorPosition - mentionQuery.text.length const mentionEndIndex = cursorPosition - const textToInsert = `${item.mention?.data?.repoName}:` + // If a branch is selected, include it in the query + const branch = item.mention?.data?.branch + const textToInsert = branch + ? `${item.mention?.data?.repoName}@${branch}:` + : `${item.mention?.data?.repoName}:` return [ currentText.slice(0, mentionStart) + @@ -265,7 +273,7 @@ export const MentionMenu: FunctionComponent< ref={ref} data-testid="mention-menu" > - + {providers.length > 0 && ( {providers} )} @@ -345,15 +353,28 @@ function getBranchHelpText( items: NonNullable, mentionQuery: MentionQuery ): string { - // Check if we have branch information from the current search + // Check if we're in branch selection mode (showing branch options) const firstItem = items[0] if (firstItem?.type === 'openctx') { const openCtxItem = firstItem as ContextItem & { type: 'openctx' - mention?: { data?: { branch?: string } } + mention?: { data?: { branch?: string; repoName?: string; directoryPath?: string } } + } + const repoName = openCtxItem.mention?.data?.repoName + const branch = openCtxItem.mention?.data?.branch + const directoryPath = openCtxItem.mention?.data?.directoryPath + + // If we're showing branch options (no directoryPath), show branch selection help + if (repoName && !directoryPath) { + // Check if this is a branch mention (title starts with @) + if (firstItem.title?.startsWith('@')) { + return '* @type to filter searches for a specific branch' + } } - if (openCtxItem.mention?.data?.branch) { - return `* Sourced from the '${openCtxItem.mention.data.branch}' branch` + + // If we're browsing directories and have branch info, show current branch + if (branch) { + return `* Sourced from the '${branch}' branch` } } diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.module.css b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.module.css index 5cfd3de29c59..15d9fe9c019f 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.module.css +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.module.css @@ -6,7 +6,6 @@ white-space: nowrap; text-overflow: ellipsis; overflow: hidden; - direction: rtl; text-align: left; } @@ -29,6 +28,4 @@ flex: 1; } -.title:hover { - direction: rtl; -} + diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index aec7e6bbf7e0..6fee94a11604 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -281,8 +281,18 @@ export interface SuggestionsRepo { name: string stars: number url: string + defaultBranch?: { + abbrevName: string + } | null + branches?: { + nodes: Array<{ + abbrevName: string + }> + } | null } + + export interface RepoSuggestionsSearchResponse { search: { results: { @@ -1041,14 +1051,16 @@ export class SourcegraphGraphQLAPIClient { } public async searchRepoSuggestions(query: string): Promise { + const searchQuery = `context:global type:repo count:10 repo:${query}` + return this.fetchSourcegraphAPI>( REPOS_SUGGESTIONS_QUERY, - { - query: `context:global type:repo count:10 repo:${query}`, - } + { query: searchQuery } ).then(response => extractDataOrError(response, data => data)) } + + public async searchFileMatches(query?: string): Promise { return this.fetchSourcegraphAPI>(FILE_MATCH_SEARCH_QUERY, { query, diff --git a/lib/shared/src/sourcegraph-api/graphql/queries.ts b/lib/shared/src/sourcegraph-api/graphql/queries.ts index eb3a0be0cb18..f8d6848764ee 100644 --- a/lib/shared/src/sourcegraph-api/graphql/queries.ts +++ b/lib/shared/src/sourcegraph-api/graphql/queries.ts @@ -151,12 +151,22 @@ export const REPOS_SUGGESTIONS_QUERY = ` name stars url + defaultBranch { + abbrevName + } + branches(first: 10) { + nodes { + abbrevName + } + } } } } } ` + + export const FILE_CONTENTS_QUERY = ` query FileContentsQuery($repoName: String!, $filePath: String!, $rev: String!) { repository(name: $repoName){ diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 3dbe75af4f61..3d1f536b44aa 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -1,5 +1,6 @@ import { CLIENT_CAPABILITIES_FIXTURE, + REMOTE_DIRECTORY_PROVIDER_URI, graphqlClient, mockClientCapabilities, mockResolvedConfig @@ -7,6 +8,11 @@ import { import { describe, expect, test, vi } from 'vitest' import { createRemoteDirectoryProvider } from './remoteDirectorySearch' +// Mock the getRepositoryMentions function +vi.mock('./common/get-repository-mentions', () => ({ + getRepositoryMentions: vi.fn(), +})) + // Mock client capabilities to avoid "clientCapabilities called before configuration was set" error mockClientCapabilities(CLIENT_CAPABILITIES_FIXTURE) @@ -83,6 +89,36 @@ describe('RemoteDirectoryProvider branch parsing', () => { expect(repo).toBe('github.com/mrdoob/three.js') expect(branch).toBe('dev') }) + + test('should handle branch names with hyphens', () => { + const [repo, branch] = extractRepoAndBranch('test-repo@feature-branch') + expect(repo).toBe('test-repo') + expect(branch).toBe('feature-branch') + }) + + test('should handle branch names with slashes', () => { + const [repo, branch] = extractRepoAndBranch('test-repo@fix/feature') + expect(repo).toBe('test-repo') + expect(branch).toBe('fix/feature') + }) + + test('should handle complex branch names with multiple special characters', () => { + const [repo, branch] = extractRepoAndBranch('my-repo@feature/fix-123_test') + expect(repo).toBe('my-repo') + expect(branch).toBe('feature/fix-123_test') + }) + + test('should handle branch names with dots', () => { + const [repo, branch] = extractRepoAndBranch('test-repo@release/v1.2.3') + expect(repo).toBe('test-repo') + expect(branch).toBe('release/v1.2.3') + }) + + test('should handle repo:directory@branch format with special characters', () => { + const [repo, branch] = extractRepoAndBranch('test-repo@feature/fix-123:src/components') + expect(repo).toBe('test-repo') + expect(branch).toBe('feature/fix-123') + }) }) }) @@ -199,6 +235,312 @@ describe('RemoteDirectoryProvider mentions', () => { 'repo:^github\\.com/mrdoob/three\\.js$@e2e file:^manual.*\\/.* select:file.directory count:10' ) }) + + test('should handle branch names with hyphens in search query', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock the graphqlClient.searchFileMatches method + const mockSearchFileMatches = { + search: { + results: { + results: [ + { + __typename: 'FileMatch', + repository: { + id: 'repo-id', + name: 'test-repo', + }, + file: { + url: '/test-repo@feature-branch/-/tree/src', + path: 'src', + commit: { + oid: 'abc123', + }, + }, + }, + ], + }, + }, + } + + vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.({ query: 'test-repo@feature-branch' }, {}) + + expect(mentions).toHaveLength(1) + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@feature-branch/-/tree/src`, + title: 'src', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + rev: 'abc123', + directoryPath: 'src', + branch: 'feature-branch', + }, + }) + + // Verify the branch name is used in the search query + expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( + 'repo:^test-repo$@feature-branch file:[^/]+\\/.* select:file.directory count:10' + ) + }) + + test('should handle repo:@branch format (colon followed by @branch)', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock the graphqlClient.searchFileMatches method + const mockSearchFileMatches = { + search: { + results: { + results: [ + { + __typename: 'FileMatch', + repository: { + id: 'repo-id', + name: 'test-repo', + }, + file: { + url: '/test-repo@feature-branch/-/tree/docs', + path: 'docs', + commit: { + oid: 'abc123', + }, + }, + }, + ], + }, + }, + } + + vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.({ query: 'test-repo:@feature-branch' }, {}) + + expect(mentions).toHaveLength(1) + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@feature-branch/-/tree/docs`, + title: 'docs', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + rev: 'abc123', + directoryPath: 'docs', + branch: 'feature-branch', + }, + }) + + // Verify the correct parameters were passed to searchFileMatches + expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( + 'repo:^test-repo$@feature-branch file:[^/]+\\/.* select:file.directory count:10' + ) + }) + + test('should handle repo:@branch/directory format', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock the graphqlClient.searchFileMatches method + const mockSearchFileMatches = { + search: { + results: { + results: [ + { + __typename: 'FileMatch', + repository: { + id: 'repo-id', + name: 'test-repo', + }, + file: { + url: '/test-repo@dev/-/tree/src/components', + path: 'src/components', + commit: { + oid: 'abc123', + }, + }, + }, + ], + }, + }, + } + + vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.({ query: 'test-repo:@dev/src' }, {}) + + expect(mentions).toHaveLength(1) + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@dev/-/tree/src/components`, + title: 'src/components', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + rev: 'abc123', + directoryPath: 'src/components', + branch: 'dev', + }, + }) + + // Verify the correct parameters were passed to searchFileMatches + expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( + 'repo:^test-repo$@dev file:^src.*\\/.* select:file.directory count:10' + ) + }) + + test('should show branch suggestions when user types repo:', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock getRepositoryMentions to return a repo with branch info + const mockGetRepositoryMentions = vi.fn().mockResolvedValue([ + { + title: 'test-repo', + providerUri: 'internal-remote-directory-search', + uri: 'https://sourcegraph.com/test-repo', + description: ' ', + data: { + repoId: 'repo-id', + repoName: 'test-repo', + defaultBranch: 'main', + branches: ['main', 'develop', 'feature-branch'], + isIgnored: false, + }, + } + ]) + + // Import and mock the getRepositoryMentions function + const { getRepositoryMentions } = await import('./common/get-repository-mentions') + vi.mocked(getRepositoryMentions).mockImplementation(mockGetRepositoryMentions) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.({ query: 'test-repo:' }, {}) + + expect(mentions).toHaveLength(4) // default branch + 2 other branches + search hint + + // Check default branch mention + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@main`, + title: '@main', + description: 'Default branch', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'main', + }, + }) + + // Check other branch mentions + expect(mentions?.[1]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@develop`, + title: '@develop', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'develop', + }, + }) + + }) + + test('should handle fuzzy branch search when user types repo:@query', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock getRepositoryMentions to return branch data + const { getRepositoryMentions } = await import('./common/get-repository-mentions') + vi.mocked(getRepositoryMentions).mockResolvedValue([ + { + title: 'test-repo', + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + uri: `${auth.serverEndpoint}/test-repo`, + description: ' ', + data: { + repoId: 'repo-id', + repoName: 'test-repo', + defaultBranch: 'main', + branches: [ + 'main', + 'feature/search-improvement', + 'feature/search-ui', + 'fix/search-bug', + 'develop' + ], + isIgnored: false, + }, + } + ]) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.({ query: 'test-repo:@feat' }, {}) + + expect(mentions).toHaveLength(2) // 2 matching branches + + // Check that getRepositoryMentions was called + expect(getRepositoryMentions).toHaveBeenCalledWith('test-repo', REMOTE_DIRECTORY_PROVIDER_URI) + + // Check that we get the matching branches (filtered by 'feat') + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@feature/search-improvement`, + title: '@feature/search-improvement', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'feature/search-improvement', + }, + }) + + expect(mentions?.[1]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@feature/search-ui`, + title: '@feature/search-ui', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'feature/search-ui', + }, + }) + + expect(mentions?.[1]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@feature/search-ui`, + title: '@feature/search-ui', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'feature/search-ui', + }, + }) + }) }) describe('RemoteDirectoryProvider directory contents', () => { @@ -259,7 +601,7 @@ describe('RemoteDirectoryProvider directory contents', () => { repoName: 'test-repo', repoID: 'repo-id', directoryPath: 'src', - rev: 'HEAD', + rev: 'develop', }, }, message: 'test query', @@ -269,14 +611,14 @@ describe('RemoteDirectoryProvider directory contents', () => { expect(items).toHaveLength(2) // Only files, not directories expect(items?.[0]).toEqual({ - url: `${auth.serverEndpoint}/test-repo@HEAD/-/blob/src/file1.ts`, + url: `${auth.serverEndpoint}/test-repo@develop/-/blob/src/file1.ts`, title: 'src/file1.ts', ai: { content: 'const foo = "bar";', }, }) expect(items?.[1]).toEqual({ - url: `${auth.serverEndpoint}/test-repo@HEAD/-/blob/src/file2.js`, + url: `${auth.serverEndpoint}/test-repo@develop/-/blob/src/file2.js`, title: 'src/file2.js', ai: { content: 'console.log("hello");', diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 1e3168a6c096..40c7ba86e569 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -3,6 +3,7 @@ import { REMOTE_DIRECTORY_PROVIDER_URI, currentResolvedConfig } from '@sourcegra import type { Item, Mention } from '@openctx/client' import { graphqlClient, isDefined, isError } from '@sourcegraph/cody-shared' import { getRepositoryMentions } from './common/get-repository-mentions' +import { getBranchMentions } from './common/branch-mentions' import { escapeRegExp } from './remoteFileSearch' import type { OpenCtxProvider } from './types' @@ -49,9 +50,14 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv const [repoName, directoryPath] = query?.split(':') || [] if (!query?.includes(':') || !repoName.trim()) { - // Check if the query contains branch specification (@branch) - if (query?.includes('@')) { + // Check if the query contains branch specification (@branch) but NOT colon + // This handles formats like "repo@branch" but not "repo:@branch" + if (query?.includes('@') && !query.includes(':')) { // Handle both @branch and @branch/directory formats + // TODO: This logic currently treats any '/' after '@' as a directory separator, + // but branch names can contain '/' (e.g., 'feature/fix-123'). + // We need better heuristics to distinguish between branch names with slashes + // and actual directory paths. const trimmedQuery = query?.trim() ?? '' const atIndex = trimmedQuery.indexOf('@') const slashIndex = atIndex >= 0 ? trimmedQuery.indexOf('/', atIndex) : -1 @@ -71,6 +77,61 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv } return await getRepositoryMentions(query?.trim() ?? '', REMOTE_DIRECTORY_PROVIDER_URI) } + + // Handle case where user types repo:@branch (colon followed by @branch) + if (directoryPath?.startsWith('@')) { + let branchQuery = directoryPath.substring(1) // Remove the @ + + // Handle trailing colon from mention menu (repo:@branch: -> @branch:) + if (branchQuery.endsWith(':')) { + branchQuery = branchQuery.slice(0, -1) // Remove trailing colon + // This is a branch selection from mention menu, show directory listing + return await getDirectoryMentions(`${repoName}@${branchQuery}`, '') + } + + // Check if this looks like a complete branch name (no spaces, reasonable length) + // vs a search query (partial, contains spaces, etc.) + const slashIndex = directoryPath.indexOf('/') + if (slashIndex > 0) { + // Format: repo:@branch/directory - treat as exact branch + const branchPart = directoryPath.substring(0, slashIndex) + const directoryPathPart = directoryPath.substring(slashIndex + 1) + return await getDirectoryMentions(`${repoName}${branchPart}`, directoryPathPart) + } + + // Use fuzzy search for empty queries (just @) or short queries that look like partial searches + // Longer queries or queries with common branch patterns are treated as exact branch names + const looksLikeSearch = branchQuery.length === 0 || + (branchQuery.length > 0 && branchQuery.length <= 6 && + !branchQuery.includes('-') && !branchQuery.includes('/') && + !branchQuery.includes('_')) + if (looksLikeSearch) { + return await getDirectoryBranchMentions(repoName, branchQuery) + } + + // Otherwise treat as exact branch name + const repoWithBranch = `${repoName}@${branchQuery}` + return await getDirectoryMentions(repoWithBranch, '') + } + + // Check if we should show branch suggestions for this repository + if (!directoryPath.trim()) { + // Check if repoName contains a branch (repo@branch format from mention menu) + if (repoName.includes('@')) { + // This is "repo@branch:" - show directory listing for this branch + return await getDirectoryMentions(repoName, '') + } + // User typed "repo:" - show branch suggestions + return await getDirectoryBranchMentions(repoName) + } + + // Check if user is searching for branches (starts with @) + if (directoryPath.startsWith('@')) { + // User typed "repo:@query" - search for branches matching query + const branchQuery = directoryPath.substring(1) // Remove the @ + return await getDirectoryBranchMentions(repoName, branchQuery) + } + return await getDirectoryMentions(repoName, directoryPath.trim()) }, @@ -96,7 +157,7 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P const [repoNamePart, branchPart] = extractRepoAndBranch(repoName) const repoRe = `^${escapeRegExp(repoNamePart)}$` const directoryRe = directoryPath ? escapeRegExp(directoryPath) : '' - const repoWithBranch = branchPart ? `${repoRe}@${branchPart}` : repoRe + const repoWithBranch = branchPart ? `${repoRe}@${escapeRegExp(branchPart)}` : repoRe // For root directory search, use a pattern that finds top-level directories const filePattern = directoryPath ? `^${directoryRe}.*\\/.*` : '[^/]+\\/.*' @@ -138,6 +199,22 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P .filter(isDefined) } +async function getDirectoryBranchMentions(repoName: string, branchQuery?: string): Promise { + const branchMentions = await getBranchMentions({ + repoName, + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + branchQuery, + }) + + // If no branch mentions found, fallback to directory search + if (branchMentions.length === 0) { + return await getDirectoryMentions(repoName, '') + } + + return branchMentions +} + + async function getDirectoryItem( _userMessage: string, // ignore content - we want all files in directory repoID: string, From e164488bd9d4c2297f40d4a5afd15de58aada481 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Sun, 20 Jul 2025 23:19:19 +0300 Subject: [PATCH 06/27] feat(context): Enhance OpenCtx with branch mentions and remote file browsing This commit introduces branch suggestions and enhances remote file browsing within OpenCtx, allowing users to easily navigate and search for files within specific branches of a repository. - Adds `getBranchMentions` to fetch branch suggestions for a given repository. - Implements branch filtering based on user input. - Adds support for browsing files in a specific branch using the `repo@branch:path` format. - Updates `createRepositoryMention` to include `defaultBranch` and `branches` in the mention data. - Modifies `getFileMentions` to include an optional branch parameter in the search query. - Creates `createBranchMentionsFromData` to create mention objects for branches. - Introduces `parseRemoteQuery` to parse the query string and extract repository name, branch, and path components. --- .../context/openctx/common/branch-mentions.ts | 173 ++++++++++++++++++ .../openctx/common/get-repository-mentions.ts | 4 +- .../src/context/openctx/remoteFileSearch.ts | 36 +++- 3 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 vscode/src/context/openctx/common/branch-mentions.ts diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts new file mode 100644 index 000000000000..49746ff63a6e --- /dev/null +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -0,0 +1,173 @@ +import type { Mention } from '@openctx/client' +import { currentResolvedConfig, isDefined } from '@sourcegraph/cody-shared' +import { getRepositoryMentions } from './get-repository-mentions' + +export interface BranchMentionOptions { + repoName: string + providerUri: string + branchQuery?: string +} + +/** + * Creates branch mentions for a repository, including branch search functionality. + */ +export async function getBranchMentions(options: BranchMentionOptions): Promise { + const { repoName, providerUri, branchQuery } = options + + // Get branch info from the repository mentions + const repoMentions = await getRepositoryMentions(repoName, providerUri) + if (!repoMentions || repoMentions.length === 0) { + return [] + } + + const repoMention = repoMentions.find(mention => mention.data?.repoName === repoName) + + if (!repoMention?.data) { + return [] + } + + const branches = (repoMention.data.branches as string[]) || [] + const defaultBranch = repoMention.data.defaultBranch as string | undefined + const repoId = repoMention.data.repoId as string + + // If no branch info available, return empty + if (branches.length === 0 && !defaultBranch) { + return [] + } + + // Filter branches if we have a search query + let filteredBranches = branches + if (branchQuery && branchQuery.trim()) { + const query = branchQuery.toLowerCase() + filteredBranches = branches.filter(branch => + branch.toLowerCase().includes(query) + ) + } + + return createBranchMentionsFromData({ + repoName, + repoId, + defaultBranch, + branches: filteredBranches, + branchQuery, + }) +} + +export interface CreateBranchMentionsOptions { + repoName: string + repoId: string + defaultBranch?: string + branches?: string[] + branchQuery?: string +} + +/** + * Creates mention objects for branches with optional browse and search hint options. + */ +export async function createBranchMentionsFromData(options: CreateBranchMentionsOptions): Promise { + const { + repoName, + repoId, + defaultBranch, + branches = [], + } = options + + const { + auth: { serverEndpoint }, + } = await currentResolvedConfig() + + const mentions: Mention[] = [] + + // Add default branch first if available and it's in the branches list + if (defaultBranch && branches.includes(defaultBranch)) { + mentions.push({ + uri: `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${defaultBranch}`, + title: `@${defaultBranch}`, + description: 'Default branch', + data: { + repoName, + repoID: repoId, + branch: defaultBranch, + }, + }) + } + + // Add other branches + for (const branch of branches) { + if (branch !== defaultBranch) { + mentions.push({ + uri: `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${branch}`, + title: `@${branch}`, + description: ' ', + data: { + repoName, + repoID: repoId, + branch, + }, + }) + } + } + + return mentions.filter(isDefined) +} + +/** + * Parses a query string to extract repository name, branch, and path components. + * Supports formats like: + * - "repo" -> { repoName: "repo" } + * - "repo:" -> { repoName: "repo", showBranches: true } + * - "repo:@" -> { repoName: "repo", branchSearch: true } + * - "repo:@branch" -> { repoName: "repo", branch: "branch" } + * - "repo:@branch:path" -> { repoName: "repo", branch: "branch", path: "path" } + * - "repo:path" -> { repoName: "repo", path: "path" } + */ +export interface ParsedQuery { + repoName: string + showBranches?: boolean + branchSearch?: boolean + branch?: string + path?: string +} + +export function parseRemoteQuery(query: string): ParsedQuery | null { + if (!query || !query.includes(':')) { + return query ? { repoName: query.trim() } : null + } + + const parts = query.split(':') + const repoName = parts[0]?.trim() + + if (!repoName) { + return null + } + + // If just "repo:", show branches + if (parts.length === 2 && !parts[1]?.trim()) { + return { repoName, showBranches: true } + } + + const secondPart = parts[1]?.trim() ?? '' + + // Handle branch selection: "repo:@" or "repo:@branch" + if (secondPart.startsWith('@')) { + const branchQuery = secondPart.substring(1) // Remove @ + + // If just "repo:@", show branch search + if (!branchQuery) { + return { repoName, branchSearch: true } + } + + // If "repo:@branch" with no path part, return branch + if (parts.length === 2) { + return { repoName, branch: branchQuery } + } + + // If "repo:@branch:path", return branch and path + const path = parts.slice(2).join(':').trim() + return { repoName, branch: branchQuery, path } + } + + // Default case: "repo:path" + const path = parts.slice(1).join(':').trim() + return { repoName, path } +} diff --git a/vscode/src/context/openctx/common/get-repository-mentions.ts b/vscode/src/context/openctx/common/get-repository-mentions.ts index caa8725e2563..a1f29a9de993 100644 --- a/vscode/src/context/openctx/common/get-repository-mentions.ts +++ b/vscode/src/context/openctx/common/get-repository-mentions.ts @@ -64,7 +64,7 @@ export async function getRepositoryMentions( ) } -type MinimalRepoMention = Pick & { current?: boolean } +type MinimalRepoMention = Pick & { current?: boolean } export async function createRepositoryMention( repo: MinimalRepoMention, @@ -83,6 +83,8 @@ export async function createRepositoryMention( data: { repoId: repo.id, repoName: repo.name, + defaultBranch: repo.defaultBranch?.abbrevName || undefined, + branches: repo.branches?.nodes?.map(branch => branch.abbrevName) || [], isIgnored: (await contextFiltersProvider.isRepoNameIgnored(repo.name)) satisfies boolean, }, } diff --git a/vscode/src/context/openctx/remoteFileSearch.ts b/vscode/src/context/openctx/remoteFileSearch.ts index 18b9fbe73d11..f0ae43cfd232 100644 --- a/vscode/src/context/openctx/remoteFileSearch.ts +++ b/vscode/src/context/openctx/remoteFileSearch.ts @@ -10,6 +10,7 @@ import { import { URI } from 'vscode-uri' import { getRepositoryMentions } from './common/get-repository-mentions' +import { getBranchMentions } from './common/branch-mentions' import type { OpenCtxProvider } from './types' const RemoteFileProvider = createRemoteFileProvider() @@ -32,6 +33,18 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider return await getRepositoryMentions(query?.trim() ?? '', REMOTE_FILE_PROVIDER_URI) } + // Check if we should show branch suggestions for this repository + if (!filePath.trim()) { + // Check if repoName contains a branch (repo@branch format from mention menu) + if (repoName.includes('@')) { + // This is "repo@branch:" - show file listing for this branch + const [repoNamePart, branch] = repoName.split('@') + return await getFileMentions(repoNamePart, '', branch) + } + // User typed "repo:" - show branch suggestions + return await getFileBranchMentions(repoName) + } + return await getFileMentions(repoName, filePath.trim()) }, @@ -49,10 +62,11 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider } } -async function getFileMentions(repoName: string, filePath?: string): Promise { +async function getFileMentions(repoName: string, filePath?: string, branch?: string): Promise { const repoRe = `^${escapeRegExp(repoName)}$` const fileRe = filePath ? escapeRegExp(filePath) : '^.*$' - const query = `repo:${repoRe} file:${fileRe} type:file count:10` + const branchPart = branch ? `@${escapeRegExp(branch)}` : '' + const query = `repo:${repoRe}${branchPart} file:${fileRe} type:file count:10` const { auth } = await currentResolvedConfig() const dataOrError = await graphqlClient.searchFileMatches(query) @@ -79,12 +93,30 @@ async function getFileMentions(repoName: string, filePath?: string): Promise { + const branchMentions = await getBranchMentions({ + repoName, + providerUri: REMOTE_FILE_PROVIDER_URI, + branchQuery, + }) + + // If no branch mentions found, fallback to file search + if (branchMentions.length === 0) { + return await getFileMentions(repoName, '') + } + + return branchMentions +} + + + async function getFileItem(repoName: string, filePath: string, rev = 'HEAD'): Promise { const { auth } = await currentResolvedConfig() const dataOrError = await graphqlClient.getFileContents(repoName, filePath, rev) From 49af34556405677704683630f8f18d49ff507661 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Mon, 21 Jul 2025 08:51:34 +0300 Subject: [PATCH 07/27] Fixed tests by using the exported functions and general cleanup --- .../mentionMenu/MentionMenu.branch.test.ts | 68 ++++++------------- .../src/mentions/mentionMenu/MentionMenu.tsx | 33 +++++---- .../src/sourcegraph-api/graphql/client.ts | 4 -- .../src/sourcegraph-api/graphql/queries.ts | 2 - .../context/openctx/common/branch-mentions.ts | 17 ++--- .../openctx/common/get-repository-mentions.ts | 4 +- .../openctx/remoteDirectorySearch.test.ts | 44 ++---------- .../context/openctx/remoteDirectorySearch.ts | 16 +++-- .../src/context/openctx/remoteFileSearch.ts | 10 +-- 9 files changed, 73 insertions(+), 125 deletions(-) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts index a0e243e72b8b..104b6c6257ff 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts @@ -1,45 +1,12 @@ -import { ContextItemSource, REMOTE_DIRECTORY_PROVIDER_URI } from '@sourcegraph/cody-shared' +import { + ContextItemSource, + type MentionQuery, + REMOTE_DIRECTORY_PROVIDER_URI, +} from '@sourcegraph/cody-shared' import type { MentionMenuData } from '@sourcegraph/cody-shared' import { describe, expect, test } from 'vitest' import { URI } from 'vscode-uri' - -// This would be imported from the MentionMenu component if it were exported -// For now, we'll create a test version of the function -function getBranchHelpText( - items: NonNullable, - mentionQuery: { text: string } -): string { - // Check if we're in branch selection mode (showing branch options) - const firstItem = items[0] - if (firstItem?.type === 'openctx') { - const openCtxItem = firstItem as any // Simplified for testing - - // If we're showing branch options (no directoryPath), show branch selection help - if (openCtxItem.mention?.data?.repoName && !openCtxItem.mention?.data?.directoryPath) { - // Check if this is a branch mention (title starts with @) - if (firstItem.title?.startsWith('@')) { - return '* @type to filter searches for a specific branch' - } - } - - // If we're browsing directories and have branch info, show current branch - if (openCtxItem.mention?.data?.branch) { - return `* Sourced from the '${openCtxItem.mention.data.branch}' branch` - } - } - - // Check if user has specified a branch in the query - if (mentionQuery.text.includes('@')) { - const branchPart = mentionQuery.text.split('@')[1] - if (branchPart) { - // Remove anything after colon (directory path) - const branchName = branchPart.split(':')[0] - return `* Sourced from the '${branchName}' branch` - } - } - - return '* Sourced from the remote default branch' -} +import { getBranchHelpText } from './MentionMenu' describe('MentionMenu branch selection', () => { test('should show default branch text when no branch is specified', () => { @@ -61,7 +28,10 @@ describe('MentionMenu branch selection', () => { }, ] - const mentionQuery = { text: 'test-repo:src' } + const mentionQuery: MentionQuery = { + text: 'test-repo:src', + provider: REMOTE_DIRECTORY_PROVIDER_URI, + } const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe('* Sourced from the remote default branch') @@ -86,7 +56,10 @@ describe('MentionMenu branch selection', () => { }, ] - const mentionQuery = { text: 'test-repo@feature-branch:src' } + const mentionQuery = { + text: 'test-repo@feature-branch:src', + provider: REMOTE_DIRECTORY_PROVIDER_URI, + } const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe("* Sourced from the 'feature-branch' branch") @@ -112,7 +85,7 @@ describe('MentionMenu branch selection', () => { }, ] - const mentionQuery = { text: 'test-repo:src' } + const mentionQuery = { text: 'test-repo:src', provider: REMOTE_DIRECTORY_PROVIDER_URI } const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe("* Sourced from the 'main' branch") @@ -138,7 +111,10 @@ describe('MentionMenu branch selection', () => { }, ] - const mentionQuery = { text: 'test-repo@query-branch:src' } + const mentionQuery = { + text: 'test-repo@query-branch:src', + provider: REMOTE_DIRECTORY_PROVIDER_URI, + } const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe("* Sourced from the 'actual-branch' branch") @@ -146,7 +122,7 @@ describe('MentionMenu branch selection', () => { test('should handle empty items array', () => { const items: MentionMenuData['items'] = [] - const mentionQuery = { text: 'test-repo@main:src' } + const mentionQuery = { text: 'test-repo@main:src', provider: REMOTE_DIRECTORY_PROVIDER_URI } const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe("* Sourced from the 'main' branch") @@ -163,7 +139,7 @@ describe('MentionMenu branch selection', () => { }, ] - const mentionQuery = { text: 'test-repo@feature:src' } + const mentionQuery = { text: 'test-repo@feature:src', provider: REMOTE_DIRECTORY_PROVIDER_URI } const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe("* Sourced from the 'feature' branch") @@ -189,7 +165,7 @@ describe('MentionMenu branch selection', () => { }, ] - const mentionQuery = { text: 'test-repo:' } + const mentionQuery = { text: 'test-repo:', provider: REMOTE_DIRECTORY_PROVIDER_URI } const result = getBranchHelpText(items!, mentionQuery) expect(result).toBe('* Select or @ search for a specific branch') diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx index 9d6f59ec827c..0d5d37ad85e9 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx @@ -349,17 +349,22 @@ function commandRowValue( return contextItemID(row) } -function getBranchHelpText( +export function getBranchHelpText( items: NonNullable, mentionQuery: MentionQuery ): string { - // Check if we're in branch selection mode (showing branch options) const firstItem = items[0] - if (firstItem?.type === 'openctx') { - const openCtxItem = firstItem as ContextItem & { - type: 'openctx' - mention?: { data?: { branch?: string; repoName?: string; directoryPath?: string } } - } + + // Type guard for openctx items + const isOpenCtxItem = ( + item: ContextItem + ): item is ContextItem & { + type: 'openctx' + mention?: { data?: { branch?: string; repoName?: string; directoryPath?: string } } + } => item.type === 'openctx' + + if (firstItem && isOpenCtxItem(firstItem)) { + const openCtxItem = firstItem const repoName = openCtxItem.mention?.data?.repoName const branch = openCtxItem.mention?.data?.branch const directoryPath = openCtxItem.mention?.data?.directoryPath @@ -368,7 +373,7 @@ function getBranchHelpText( if (repoName && !directoryPath) { // Check if this is a branch mention (title starts with @) if (firstItem.title?.startsWith('@')) { - return '* @type to filter searches for a specific branch' + return '* Select or @ search for a specific branch' } } @@ -379,11 +384,13 @@ function getBranchHelpText( } // Check if user has specified a branch in the query - if (mentionQuery.text.includes('@')) { - const branchPart = mentionQuery.text.split('@')[1] - if (branchPart) { - // Remove anything after colon (directory path) - const branchName = branchPart.split(':')[0] + const atIndex = mentionQuery.text.indexOf('@') + if (atIndex !== -1 && atIndex < mentionQuery.text.length - 1) { + const afterAt = mentionQuery.text.slice(atIndex + 1) + const colonIndex = afterAt.indexOf(':') + const branchName = colonIndex !== -1 ? afterAt.slice(0, colonIndex) : afterAt + + if (branchName.trim()) { return `* Sourced from the '${branchName}' branch` } } diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index 6fee94a11604..f96e9c4f1bc9 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -291,8 +291,6 @@ export interface SuggestionsRepo { } | null } - - export interface RepoSuggestionsSearchResponse { search: { results: { @@ -1059,8 +1057,6 @@ export class SourcegraphGraphQLAPIClient { ).then(response => extractDataOrError(response, data => data)) } - - public async searchFileMatches(query?: string): Promise { return this.fetchSourcegraphAPI>(FILE_MATCH_SEARCH_QUERY, { query, diff --git a/lib/shared/src/sourcegraph-api/graphql/queries.ts b/lib/shared/src/sourcegraph-api/graphql/queries.ts index f8d6848764ee..0f7df7a8fa61 100644 --- a/lib/shared/src/sourcegraph-api/graphql/queries.ts +++ b/lib/shared/src/sourcegraph-api/graphql/queries.ts @@ -165,8 +165,6 @@ export const REPOS_SUGGESTIONS_QUERY = ` } ` - - export const FILE_CONTENTS_QUERY = ` query FileContentsQuery($repoName: String!, $filePath: String!, $rev: String!) { repository(name: $repoName){ diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts index 49746ff63a6e..82f7287eeb7f 100644 --- a/vscode/src/context/openctx/common/branch-mentions.ts +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -39,9 +39,7 @@ export async function getBranchMentions(options: BranchMentionOptions): Promise< let filteredBranches = branches if (branchQuery && branchQuery.trim()) { const query = branchQuery.toLowerCase() - filteredBranches = branches.filter(branch => - branch.toLowerCase().includes(query) - ) + filteredBranches = branches.filter(branch => branch.toLowerCase().includes(query)) } return createBranchMentionsFromData({ @@ -64,13 +62,10 @@ export interface CreateBranchMentionsOptions { /** * Creates mention objects for branches with optional browse and search hint options. */ -export async function createBranchMentionsFromData(options: CreateBranchMentionsOptions): Promise { - const { - repoName, - repoId, - defaultBranch, - branches = [], - } = options +export async function createBranchMentionsFromData( + options: CreateBranchMentionsOptions +): Promise { + const { repoName, repoId, defaultBranch, branches = [] } = options const { auth: { serverEndpoint }, @@ -151,7 +146,7 @@ export function parseRemoteQuery(query: string): ParsedQuery | null { // Handle branch selection: "repo:@" or "repo:@branch" if (secondPart.startsWith('@')) { const branchQuery = secondPart.substring(1) // Remove @ - + // If just "repo:@", show branch search if (!branchQuery) { return { repoName, branchSearch: true } diff --git a/vscode/src/context/openctx/common/get-repository-mentions.ts b/vscode/src/context/openctx/common/get-repository-mentions.ts index a1f29a9de993..c0b5f0dcc573 100644 --- a/vscode/src/context/openctx/common/get-repository-mentions.ts +++ b/vscode/src/context/openctx/common/get-repository-mentions.ts @@ -64,7 +64,9 @@ export async function getRepositoryMentions( ) } -type MinimalRepoMention = Pick & { current?: boolean } +type MinimalRepoMention = Pick & { + current?: boolean +} export async function createRepositoryMention( repo: MinimalRepoMention, diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 3d1f536b44aa..9ea6cf6f6757 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -3,10 +3,10 @@ import { REMOTE_DIRECTORY_PROVIDER_URI, graphqlClient, mockClientCapabilities, - mockResolvedConfig + mockResolvedConfig, } from '@sourcegraph/cody-shared' import { describe, expect, test, vi } from 'vitest' -import { createRemoteDirectoryProvider } from './remoteDirectorySearch' +import { createRemoteDirectoryProvider, extractRepoAndBranch } from './remoteDirectorySearch' // Mock the getRepositoryMentions function vi.mock('./common/get-repository-mentions', () => ({ @@ -18,28 +18,6 @@ mockClientCapabilities(CLIENT_CAPABILITIES_FIXTURE) const auth = { serverEndpoint: 'https://sourcegraph.com' } -// Test the extractRepoAndBranch function logic -function extractRepoAndBranch(input: string): [string, string | undefined] { - // Handle case where input contains a colon (repo:directory@branch) - const colonIndex = input.indexOf(':') - if (colonIndex !== -1) { - const repoPart = input.substring(0, colonIndex) - const atIndex = repoPart.indexOf('@') - if (atIndex !== -1) { - return [repoPart.substring(0, atIndex), repoPart.substring(atIndex + 1)] - } - return [repoPart, undefined] - } - - // Handle simple case: repo@branch or repo - const atIndex = input.indexOf('@') - if (atIndex !== -1) { - return [input.substring(0, atIndex), input.substring(atIndex + 1)] - } - - return [input, undefined] -} - describe('RemoteDirectoryProvider branch parsing', () => { describe('extractRepoAndBranch', () => { test('should extract repo name without branch', () => { @@ -90,12 +68,6 @@ describe('RemoteDirectoryProvider branch parsing', () => { expect(branch).toBe('dev') }) - test('should handle branch names with hyphens', () => { - const [repo, branch] = extractRepoAndBranch('test-repo@feature-branch') - expect(repo).toBe('test-repo') - expect(branch).toBe('feature-branch') - }) - test('should handle branch names with slashes', () => { const [repo, branch] = extractRepoAndBranch('test-repo@fix/feature') expect(repo).toBe('test-repo') @@ -167,7 +139,7 @@ describe('RemoteDirectoryProvider mentions', () => { title: 'docs', description: ' ', data: { - branch: "dev", + branch: 'dev', repoName: 'github.com/mrdoob/three.js', repoID: 'repo-id', rev: 'abc123', @@ -429,7 +401,7 @@ describe('RemoteDirectoryProvider mentions', () => { branches: ['main', 'develop', 'feature-branch'], isIgnored: false, }, - } + }, ]) // Import and mock the getRepositoryMentions function @@ -439,7 +411,7 @@ describe('RemoteDirectoryProvider mentions', () => { const provider = createRemoteDirectoryProvider() const mentions = await provider.mentions?.({ query: 'test-repo:' }, {}) - expect(mentions).toHaveLength(4) // default branch + 2 other branches + search hint + expect(mentions).toHaveLength(3) // default branch + 2 other branches // Check default branch mention expect(mentions?.[0]).toEqual({ @@ -464,7 +436,6 @@ describe('RemoteDirectoryProvider mentions', () => { branch: 'develop', }, }) - }) test('should handle fuzzy branch search when user types repo:@query', async () => { @@ -492,11 +463,11 @@ describe('RemoteDirectoryProvider mentions', () => { 'feature/search-improvement', 'feature/search-ui', 'fix/search-bug', - 'develop' + 'develop', ], isIgnored: false, }, - } + }, ]) const provider = createRemoteDirectoryProvider() @@ -552,7 +523,6 @@ describe('RemoteDirectoryProvider directory contents', () => { }, }) - // Mock contextSearch to return empty array to trigger fallback vi.spyOn(graphqlClient, 'contextSearch').mockResolvedValue([]) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 40c7ba86e569..183740b79df1 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -2,8 +2,8 @@ import { REMOTE_DIRECTORY_PROVIDER_URI, currentResolvedConfig } from '@sourcegra import type { Item, Mention } from '@openctx/client' import { graphqlClient, isDefined, isError } from '@sourcegraph/cody-shared' -import { getRepositoryMentions } from './common/get-repository-mentions' import { getBranchMentions } from './common/branch-mentions' +import { getRepositoryMentions } from './common/get-repository-mentions' import { escapeRegExp } from './remoteFileSearch' import type { OpenCtxProvider } from './types' @@ -12,7 +12,7 @@ import type { OpenCtxProvider } from './types' * Extracts repo name and optional branch from a string. * Supports formats: "repo@branch", "repo", or "repo:directory@branch" */ -function extractRepoAndBranch(input: string): [string, string | undefined] { +export function extractRepoAndBranch(input: string): [string, string | undefined] { // Handle case where input contains a colon (repo:directory@branch) const colonIndex = input.indexOf(':') if (colonIndex !== -1) { @@ -101,10 +101,13 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv // Use fuzzy search for empty queries (just @) or short queries that look like partial searches // Longer queries or queries with common branch patterns are treated as exact branch names - const looksLikeSearch = branchQuery.length === 0 || - (branchQuery.length > 0 && branchQuery.length <= 6 && - !branchQuery.includes('-') && !branchQuery.includes('/') && - !branchQuery.includes('_')) + const looksLikeSearch = + branchQuery.length === 0 || + (branchQuery.length > 0 && + branchQuery.length <= 6 && + !branchQuery.includes('-') && + !branchQuery.includes('/') && + !branchQuery.includes('_')) if (looksLikeSearch) { return await getDirectoryBranchMentions(repoName, branchQuery) } @@ -214,7 +217,6 @@ async function getDirectoryBranchMentions(repoName: string, branchQuery?: string return branchMentions } - async function getDirectoryItem( _userMessage: string, // ignore content - we want all files in directory repoID: string, diff --git a/vscode/src/context/openctx/remoteFileSearch.ts b/vscode/src/context/openctx/remoteFileSearch.ts index f0ae43cfd232..6b0cf90b7ac5 100644 --- a/vscode/src/context/openctx/remoteFileSearch.ts +++ b/vscode/src/context/openctx/remoteFileSearch.ts @@ -9,8 +9,8 @@ import { } from '@sourcegraph/cody-shared' import { URI } from 'vscode-uri' -import { getRepositoryMentions } from './common/get-repository-mentions' import { getBranchMentions } from './common/branch-mentions' +import { getRepositoryMentions } from './common/get-repository-mentions' import type { OpenCtxProvider } from './types' const RemoteFileProvider = createRemoteFileProvider() @@ -62,7 +62,11 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider } } -async function getFileMentions(repoName: string, filePath?: string, branch?: string): Promise { +async function getFileMentions( + repoName: string, + filePath?: string, + branch?: string +): Promise { const repoRe = `^${escapeRegExp(repoName)}$` const fileRe = filePath ? escapeRegExp(filePath) : '^.*$' const branchPart = branch ? `@${escapeRegExp(branch)}` : '' @@ -115,8 +119,6 @@ async function getFileBranchMentions(repoName: string, branchQuery?: string): Pr return branchMentions } - - async function getFileItem(repoName: string, filePath: string, rev = 'HEAD'): Promise { const { auth } = await currentResolvedConfig() const dataOrError = await graphqlClient.getFileContents(repoName, filePath, rev) From 0052d3f5c8e139b39c3f1245282905a81a54efe8 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Mon, 21 Jul 2025 09:26:19 +0300 Subject: [PATCH 08/27] pnpm biome fix --- vscode/src/context/openctx/common/branch-mentions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts index 82f7287eeb7f..616e231d06b3 100644 --- a/vscode/src/context/openctx/common/branch-mentions.ts +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -37,7 +37,7 @@ export async function getBranchMentions(options: BranchMentionOptions): Promise< // Filter branches if we have a search query let filteredBranches = branches - if (branchQuery && branchQuery.trim()) { + if (branchQuery?.trim()) { const query = branchQuery.toLowerCase() filteredBranches = branches.filter(branch => branch.toLowerCase().includes(query)) } From 3473b194ff44e818df1706c2aa6c22c6299722be Mon Sep 17 00:00:00 2001 From: David Ichim Date: Tue, 22 Jul 2025 15:26:37 +0300 Subject: [PATCH 09/27] fixed remote file filtering once we choose the branch --- vscode/src/context/openctx/remoteFileSearch.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/vscode/src/context/openctx/remoteFileSearch.ts b/vscode/src/context/openctx/remoteFileSearch.ts index 6b0cf90b7ac5..2f6ea03260f6 100644 --- a/vscode/src/context/openctx/remoteFileSearch.ts +++ b/vscode/src/context/openctx/remoteFileSearch.ts @@ -34,18 +34,13 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider } // Check if we should show branch suggestions for this repository - if (!filePath.trim()) { - // Check if repoName contains a branch (repo@branch format from mention menu) - if (repoName.includes('@')) { - // This is "repo@branch:" - show file listing for this branch - const [repoNamePart, branch] = repoName.split('@') - return await getFileMentions(repoNamePart, '', branch) - } - // User typed "repo:" - show branch suggestions - return await getFileBranchMentions(repoName) + // Check if repoName contains a branch (repo@branch format from mention menu) + if (repoName.includes('@')) { + // This is "repo@branch:" - show file listing for this branch + const [repoNamePart, branch] = repoName.split('@') + return await getFileMentions(repoNamePart, filePath.trim(), branch) } - - return await getFileMentions(repoName, filePath.trim()) + return await getFileBranchMentions(repoName) }, async items({ mention }) { From 03147354996b4a83f6a21d40d802c111279e5d5e Mon Sep 17 00:00:00 2001 From: David Ichim Date: Sun, 27 Jul 2025 21:04:59 +0300 Subject: [PATCH 10/27] feat(context): Improve remote directory search using getDirectoryContents This commit enhances the remote directory search functionality by utilizing the `getDirectoryContents` API to fetch directory contents directly, improving performance and reliability. - Replaces the previous implementation that relied on `contextSearch` and a fallback to `getDirectoryContents` with a direct call to `getDirectoryContents`. - Adds a new `fetchContentFromRawURL` method to fetch the content of files from their raw URLs. - Updates the `getDirectoryItem` function to use the `getDirectoryContents` API and `fetchContentFromRawURL` to retrieve file entries and their content. - Filters out directories and binary files from the results. - Exports `DirectoryContentsResponse` type. - Updates the `GetDirectoryContents` query to include `rawURL` and `binary` fields. --- lib/shared/src/index.ts | 5 +- .../src/sourcegraph-api/graphql/client.ts | 38 +++++++++- .../src/sourcegraph-api/graphql/queries.ts | 3 +- .../context/openctx/remoteDirectorySearch.ts | 76 ++++++------------- 4 files changed, 66 insertions(+), 56 deletions(-) diff --git a/lib/shared/src/index.ts b/lib/shared/src/index.ts index cfb9014f6571..eb55df5bbffa 100644 --- a/lib/shared/src/index.ts +++ b/lib/shared/src/index.ts @@ -343,7 +343,10 @@ export { promise, type ReadonlyDeep, } from './utils' -export type { CurrentUserCodySubscription } from './sourcegraph-api/graphql/client' +export type { + CurrentUserCodySubscription, + DirectoryContentsResponse, +} from './sourcegraph-api/graphql/client' export * from './auth/types' export * from './auth/tokens' export * from './auth/referral' diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index f96e9c4f1bc9..b41489bbf6dc 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -379,7 +379,7 @@ interface FileContentsResponse { } | null } -interface DirectoryContentsResponse { +export interface DirectoryContentsResponse { repository: { commit: { tree: { @@ -388,7 +388,8 @@ interface DirectoryContentsResponse { path: string byteSize?: number url: string - content?: string + rawURL: string + binary?: boolean isDirectory?: boolean }> } | null @@ -1090,6 +1091,39 @@ export class SourcegraphGraphQLAPIClient { ).then(response => extractDataOrError(response, data => data)) } + public async fetchContentFromRawURL(rawURL: string, signal?: AbortSignal): Promise { + try { + const config = await firstValueFrom(this.config!) + signal?.throwIfAborted() + + const headers = new Headers() + addTraceparent(headers) + addCodyClientIdentificationHeaders(headers) + + const url = new URL(rawURL, config.auth.serverEndpoint) + await addAuthHeaders(config.auth, headers, url) + + const { abortController } = dependentAbortControllerWithTimeout(signal) + const response = await fetch(url, { + method: 'GET', + headers, + signal: abortController.signal, + }) + + if (!response.ok) { + return new Error( + `Failed to fetch raw content: ${response.status} ${response.statusText}` + ) + } + + return await response.text() + } catch (error: any) { + return isAbortError(error) + ? error + : new Error(`Failed to fetch raw content: ${error.message}`) + } + } + public async getRepoId(repoName: string): Promise { return this.fetchSourcegraphAPI>(REPOSITORY_ID_QUERY, { name: repoName, diff --git a/lib/shared/src/sourcegraph-api/graphql/queries.ts b/lib/shared/src/sourcegraph-api/graphql/queries.ts index 0f7df7a8fa61..6d8ef317fa8d 100644 --- a/lib/shared/src/sourcegraph-api/graphql/queries.ts +++ b/lib/shared/src/sourcegraph-api/graphql/queries.ts @@ -774,7 +774,8 @@ query GetDirectoryContents($repoName: String!, $revision: String!, $path: String path byteSize url - content + rawURL + binary } ... on GitTree { name diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 183740b79df1..f32d166f62b4 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -142,11 +142,8 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv if (!mention?.data?.repoID || !mention?.data?.directoryPath || !message) { return [] } - const revision = mention.data.branch ?? mention.data.rev return await getDirectoryItem( - message, - mention.data.repoID as string, mention.data.repoName as string, mention.data.directoryPath as string, revision as string @@ -218,70 +215,45 @@ async function getDirectoryBranchMentions(repoName: string, branchQuery?: string } async function getDirectoryItem( - _userMessage: string, // ignore content - we want all files in directory - repoID: string, repoName: string, directoryPath: string, revision?: string ): Promise { - const filePatterns = [`^${escapeRegExp(directoryPath)}.*`] - // Use directory basename as search query since contextSearch requires non-empty query - const searchQuery = directoryPath.split('/').pop() || '.' - const dataOrError = await graphqlClient.contextSearch({ - repoIDs: [repoID], - query: searchQuery, - filePatterns, - revision, - }) - + const dataOrError = await graphqlClient.getDirectoryContents(repoName, directoryPath, revision) if (isError(dataOrError) || dataOrError === null) { return [] } - // If contextSearch returns no results, try fallback to getDirectoryContents - if (dataOrError.length === 0) { - const fallbackData = await graphqlClient.getDirectoryContents(repoName, directoryPath, revision) - if (!isError(fallbackData) && fallbackData !== null) { - const entries = fallbackData.repository?.commit?.tree?.entries || [] - const { - auth: { serverEndpoint }, - } = await currentResolvedConfig() - - return entries - .filter(entry => entry.content && !entry.isDirectory) // Only include files with content - .map(entry => ({ - url: revision - ? `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${revision}/-/blob/${ - entry.path - }` - : `${serverEndpoint.replace(/\/$/, '')}${entry.url}`, - title: entry.path, - ai: { - content: entry.content, - }, - })) as Item[] - } - return [] - } - + const entries = dataOrError.repository?.commit?.tree?.entries || [] const { auth: { serverEndpoint }, } = await currentResolvedConfig() - return dataOrError.map( - node => - ({ + const items: Item[] = [] + for (const entry of entries) { + if (entry.isDirectory || entry.binary) { + continue + } + + let content = '' + const rawContent = await graphqlClient.fetchContentFromRawURL(entry.rawURL) + if (!isError(rawContent)) { + content = rawContent + } + + if (content) { + items.push({ url: revision - ? `${serverEndpoint.replace(/\/$/, '')}/${node.repoName}@${revision}/-/blob/${ - node.path - }` - : node.uri.toString(), - title: node.path, + ? `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${revision}/-/blob/${entry.path}` + : `${serverEndpoint.replace(/\/$/, '')}${entry.url}`, + title: entry.path, ai: { - content: node.content, + content, }, - }) as Item - ) + }) + } + } + return items } export default RemoteDirectoryProvider From 4ffcf8674f304b3c66703fe0ec61a9b991eb2ed3 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Mon, 28 Jul 2025 09:35:48 +0300 Subject: [PATCH 11/27] Removed experimental flag from remote directoy mention menu --- .../src/mentions/mentionMenu/MentionMenu.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx index 0d5d37ad85e9..ff81ddb6a969 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx @@ -445,14 +445,6 @@ function getItemsHeading( ? 'Directory - Select or search for a directory*' : 'Directory - Select a repository*'} -
- Experimental -
) } From 0f98b3a8979ef811145994e4e089d59be133a24c Mon Sep 17 00:00:00 2001 From: David Ichim Date: Mon, 28 Jul 2025 11:07:21 +0300 Subject: [PATCH 12/27] WIP implementing repo -> branch -> directory selection for remote directories --- .../src/mentions/mentionMenu/MentionMenu.tsx | 17 +- .../openctx/remoteDirectorySearch.test.ts | 159 +++++++++++++++++- .../context/openctx/remoteDirectorySearch.ts | 122 +++++++------- 3 files changed, 228 insertions(+), 70 deletions(-) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx index ff81ddb6a969..811553861a61 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx @@ -416,6 +416,19 @@ function getEmptyLabel( return parentItem.emptyLabel ?? 'No results' } +function getRemoteDirectoryHeading(queryText: string): string { + if (!queryText.includes(':')) { + return 'Directory - Select a repository*' + } + + // If path includes @, we're in directory selection/filtering mode + if (queryText.includes('@')) { + return 'Directory - Select or search for a directory*' + } + + return 'Directory - Select or search for a branch*' +} + function getItemsHeading( parentItem: ContextMentionProviderMetadata | null, mentionQuery: MentionQuery @@ -441,9 +454,7 @@ function getItemsHeading( return (
- {mentionQuery.text.includes(':') - ? 'Directory - Select or search for a directory*' - : 'Directory - Select a repository*'} + {getRemoteDirectoryHeading(mentionQuery.text)}
) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 9ea6cf6f6757..d69dcdd3ea27 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -512,6 +512,148 @@ describe('RemoteDirectoryProvider mentions', () => { }, }) }) + + test('should handle branch filtering without @ prefix', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock getRepositoryMentions to return branch data + const { getRepositoryMentions } = await import('./common/get-repository-mentions') + vi.mocked(getRepositoryMentions).mockResolvedValue([ + { + title: 'test-repo', + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + uri: `${auth.serverEndpoint}/test-repo`, + description: ' ', + data: { + repoId: 'repo-id', + repoName: 'test-repo', + defaultBranch: 'main', + branches: [ + 'main', + 'feature/search-improvement', + 'feature/search-ui', + 'fix/search-bug', + 'develop', + ], + isIgnored: false, + }, + }, + ]) + + const provider = createRemoteDirectoryProvider() + // Test filtering branches without @ prefix - should match branches first + const mentions = await provider.mentions?.({ query: 'test-repo:feat' }, {}) + + expect(mentions).toHaveLength(2) // 2 matching branches + + // Check that getRepositoryMentions was called + expect(getRepositoryMentions).toHaveBeenCalledWith('test-repo', REMOTE_DIRECTORY_PROVIDER_URI) + + // Check that we get the matching branches (filtered by 'feat') + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@feature/search-improvement`, + title: '@feature/search-improvement', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'feature/search-improvement', + }, + }) + + expect(mentions?.[1]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@feature/search-ui`, + title: '@feature/search-ui', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'feature/search-ui', + }, + }) + }) + + test('should fallback to directory search when no branches match', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock getRepositoryMentions to return branch data (no branches matching 'src') + const { getRepositoryMentions } = await import('./common/get-repository-mentions') + vi.mocked(getRepositoryMentions).mockResolvedValue([ + { + title: 'test-repo', + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + uri: `${auth.serverEndpoint}/test-repo`, + description: ' ', + data: { + repoId: 'repo-id', + repoName: 'test-repo', + defaultBranch: 'main', + branches: ['main', 'develop', 'feature-branch'], + isIgnored: false, + }, + }, + ]) + + // Mock directory search + const mockSearchFileMatches = { + search: { + results: { + results: [ + { + __typename: 'FileMatch', + repository: { + id: 'repo-id', + name: 'test-repo', + }, + file: { + url: '/test-repo/-/tree/src', + path: 'src', + commit: { + oid: 'abc123', + }, + }, + }, + ], + }, + }, + } + + vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) + + const provider = createRemoteDirectoryProvider() + // Test with 'src' - no branches match, should fallback to directory search + const mentions = await provider.mentions?.({ query: 'test-repo:src' }, {}) + + expect(mentions).toHaveLength(1) // 1 directory result + + // Should have called directory search + expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( + 'repo:^test-repo$ file:^src.*\\/.* select:file.directory count:10' + ) + + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo/-/tree/src`, + title: 'src', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + rev: 'abc123', + directoryPath: 'src', + branch: undefined, + }, + }) + }) }) describe('RemoteDirectoryProvider directory contents', () => { @@ -536,20 +678,21 @@ describe('RemoteDirectoryProvider directory contents', () => { name: 'file1.ts', path: 'src/file1.ts', url: '/repo/-/blob/src/file1.ts', - content: 'const foo = "bar";', + rawURL: '/raw/repo/-/blob/src/file1.ts', byteSize: 18, }, { name: 'file2.js', path: 'src/file2.js', url: '/repo/-/blob/src/file2.js', - content: 'console.log("hello");', + rawURL: '/raw/repo/-/blob/src/file2.js', byteSize: 21, }, { name: 'subdir', path: 'src/subdir', url: '/repo/-/tree/src/subdir', + rawURL: '/raw/repo/-/tree/src/subdir', isDirectory: true, }, ], @@ -560,6 +703,18 @@ describe('RemoteDirectoryProvider directory contents', () => { vi.spyOn(graphqlClient, 'getDirectoryContents').mockResolvedValue(mockDirectoryContents) + // Mock fetchContentFromRawURL to return content for each file + vi.spyOn(graphqlClient, 'fetchContentFromRawURL') + .mockImplementation(async (rawURL: string) => { + if (rawURL.includes('file1.ts')) { + return 'const foo = "bar";' + } + if (rawURL.includes('file2.js')) { + return 'console.log("hello");' + } + return new Error('File not found') + }) + const provider = createRemoteDirectoryProvider() const items = await provider.items?.( { diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index f32d166f62b4..7da3ffaef600 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -47,95 +47,92 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv }, async mentions({ query }) { - const [repoName, directoryPath] = query?.split(':') || [] - - if (!query?.includes(':') || !repoName.trim()) { - // Check if the query contains branch specification (@branch) but NOT colon - // This handles formats like "repo@branch" but not "repo:@branch" - if (query?.includes('@') && !query.includes(':')) { - // Handle both @branch and @branch/directory formats - // TODO: This logic currently treats any '/' after '@' as a directory separator, - // but branch names can contain '/' (e.g., 'feature/fix-123'). - // We need better heuristics to distinguish between branch names with slashes - // and actual directory paths. - const trimmedQuery = query?.trim() ?? '' - const atIndex = trimmedQuery.indexOf('@') - const slashIndex = atIndex >= 0 ? trimmedQuery.indexOf('/', atIndex) : -1 + if (!query?.trim()) { + return await getRepositoryMentions('', REMOTE_DIRECTORY_PROVIDER_URI) + } + + const trimmedQuery = query.trim() + // Step 1: Repository selection (no colon means we're still selecting repos) + if (!trimmedQuery.includes(':')) { + // Handle repo@branch format (direct branch specification) + if (trimmedQuery.includes('@')) { + const slashIndex = trimmedQuery.indexOf('/', trimmedQuery.indexOf('@')) if (slashIndex > 0) { // Format: repo@branch/directory const repoWithBranch = trimmedQuery.substring(0, slashIndex) const directoryPathPart = trimmedQuery.substring(slashIndex + 1) return await getDirectoryMentions(repoWithBranch, directoryPathPart) } - - // Format: repo@branch (root directory search) - const [repoNamePart] = extractRepoAndBranch(trimmedQuery) - if (repoNamePart.trim()) { - return await getDirectoryMentions(trimmedQuery, '') - } + // Format: repo@branch (show root directories) + return await getDirectoryMentions(trimmedQuery, '') } - return await getRepositoryMentions(query?.trim() ?? '', REMOTE_DIRECTORY_PROVIDER_URI) + // No @ symbol, still selecting repositories + return await getRepositoryMentions(trimmedQuery, REMOTE_DIRECTORY_PROVIDER_URI) } - // Handle case where user types repo:@branch (colon followed by @branch) - if (directoryPath?.startsWith('@')) { - let branchQuery = directoryPath.substring(1) // Remove the @ + // Step 2: Parse repo:path format + const [repoName, pathPart] = trimmedQuery.split(':', 2) + if (!repoName.trim()) { + return await getRepositoryMentions('', REMOTE_DIRECTORY_PROVIDER_URI) + } + + // Step 3: Branch selection/filtering (path starts with @) + if (pathPart?.startsWith('@')) { + const branchQuery = pathPart.substring(1) // Remove @ - // Handle trailing colon from mention menu (repo:@branch: -> @branch:) + // Handle trailing colon from mention menu selection (repo:@branch:) if (branchQuery.endsWith(':')) { - branchQuery = branchQuery.slice(0, -1) // Remove trailing colon - // This is a branch selection from mention menu, show directory listing - return await getDirectoryMentions(`${repoName}@${branchQuery}`, '') + const cleanBranchName = branchQuery.slice(0, -1) + return await getDirectoryMentions(`${repoName}@${cleanBranchName}`, '') } - // Check if this looks like a complete branch name (no spaces, reasonable length) - // vs a search query (partial, contains spaces, etc.) - const slashIndex = directoryPath.indexOf('/') + // Handle branch/directory format (repo:@branch/directory) + const slashIndex = branchQuery.indexOf('/') if (slashIndex > 0) { - // Format: repo:@branch/directory - treat as exact branch - const branchPart = directoryPath.substring(0, slashIndex) - const directoryPathPart = directoryPath.substring(slashIndex + 1) - return await getDirectoryMentions(`${repoName}${branchPart}`, directoryPathPart) + const branchName = branchQuery.substring(0, slashIndex) + const directoryPath = branchQuery.substring(slashIndex + 1) + return await getDirectoryMentions(`${repoName}@${branchName}`, directoryPath) } - // Use fuzzy search for empty queries (just @) or short queries that look like partial searches - // Longer queries or queries with common branch patterns are treated as exact branch names - const looksLikeSearch = - branchQuery.length === 0 || - (branchQuery.length > 0 && - branchQuery.length <= 6 && - !branchQuery.includes('-') && - !branchQuery.includes('/') && - !branchQuery.includes('_')) - if (looksLikeSearch) { - return await getDirectoryBranchMentions(repoName, branchQuery) + // Check if this looks like a complete branch name vs a search query + // Complete branch names typically have hyphens, slashes, underscores, or are longer + const looksLikeCompleteBranch = branchQuery.length > 6 || + branchQuery.includes('-') || + branchQuery.includes('/') || + branchQuery.includes('_') + + if (looksLikeCompleteBranch) { + // Treat as exact branch name - show directories for this branch + return await getDirectoryMentions(`${repoName}@${branchQuery}`, '') } - // Otherwise treat as exact branch name - const repoWithBranch = `${repoName}@${branchQuery}` - return await getDirectoryMentions(repoWithBranch, '') + // Short query or empty - show branch filtering/search + return await getDirectoryBranchMentions(repoName, branchQuery) } - // Check if we should show branch suggestions for this repository - if (!directoryPath.trim()) { - // Check if repoName contains a branch (repo@branch format from mention menu) + // Step 4: Directory selection/filtering + if (!pathPart?.trim()) { + // Empty path after colon - check if repo has branch specified if (repoName.includes('@')) { - // This is "repo@branch:" - show directory listing for this branch + // repo@branch: - show directories for this branch return await getDirectoryMentions(repoName, '') } - // User typed "repo:" - show branch suggestions + // repo: - show branch selection return await getDirectoryBranchMentions(repoName) } - // Check if user is searching for branches (starts with @) - if (directoryPath.startsWith('@')) { - // User typed "repo:@query" - search for branches matching query - const branchQuery = directoryPath.substring(1) // Remove the @ - return await getDirectoryBranchMentions(repoName, branchQuery) + // Step 5: Handle repo:query - could be branch filtering or directory search + // First try branch filtering (this allows filtering without @ prefix) + const branchMentions = await getDirectoryBranchMentions(repoName, pathPart.trim()) + + // If we found matching branches, return them + if (branchMentions.length > 0) { + return branchMentions } - return await getDirectoryMentions(repoName, directoryPath.trim()) + // No matching branches found, treat as directory search + return await getDirectoryMentions(repoName, pathPart.trim()) }, async items({ mention, message }) { @@ -206,11 +203,6 @@ async function getDirectoryBranchMentions(repoName: string, branchQuery?: string branchQuery, }) - // If no branch mentions found, fallback to directory search - if (branchMentions.length === 0) { - return await getDirectoryMentions(repoName, '') - } - return branchMentions } From e19822959d21f19977d432542c96cd061e96d417 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Tue, 29 Jul 2025 22:50:16 +0300 Subject: [PATCH 13/27] repository branch searching beyond the first 10 results --- .../src/sourcegraph-api/graphql/client.ts | 34 ++++++ .../src/sourcegraph-api/graphql/queries.ts | 27 +++-- .../context/openctx/common/branch-mentions.ts | 51 ++++++++- .../openctx/remoteDirectorySearch.test.ts | 101 ++++++++++++++++++ .../context/openctx/remoteDirectorySearch.ts | 2 +- 5 files changed, 205 insertions(+), 10 deletions(-) diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index b41489bbf6dc..a0a0dd5fc073 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -62,6 +62,7 @@ import { PROMPT_TAGS_QUERY, PromptsOrderBy, RECORD_TELEMETRY_EVENTS_MUTATION, + REPOSITORY_BRANCHES_QUERY, REPOSITORY_IDS_QUERY, REPOSITORY_ID_QUERY, REPOSITORY_LIST_QUERY, @@ -411,6 +412,22 @@ export interface RepositoryIdsResponse { } } +export interface RepositoryBranchesResponse { + repository: { + id: string + name: string + defaultBranch?: { + abbrevName: string + } | null + branches: { + nodes: Array<{ + abbrevName: string + }> + totalCount: number + } + } | null +} + export interface SearchAttributionResponse { snippetAttribution: { limitHit: boolean @@ -1160,6 +1177,23 @@ export class SourcegraphGraphQLAPIClient { return isError(result) ? null : result } + public async getRepositoryBranches( + repoName: string, + first = 10, + query?: string, + signal?: AbortSignal + ): Promise { + return this.fetchSourcegraphAPI>( + REPOSITORY_BRANCHES_QUERY, + { + repoName, + first, + query, + }, + signal + ).then(response => extractDataOrError(response, data => data)) + } + /** * Checks if the current site version is valid based on the given criteria. * diff --git a/lib/shared/src/sourcegraph-api/graphql/queries.ts b/lib/shared/src/sourcegraph-api/graphql/queries.ts index 6d8ef317fa8d..7e4c28ae5cf7 100644 --- a/lib/shared/src/sourcegraph-api/graphql/queries.ts +++ b/lib/shared/src/sourcegraph-api/graphql/queries.ts @@ -165,6 +165,24 @@ export const REPOS_SUGGESTIONS_QUERY = ` } ` +export const REPOSITORY_BRANCHES_QUERY = ` + query RepositoryBranches($repoName: String!, $first: Int!, $query: String) { + repository(name: $repoName) { + id + name + defaultBranch { + abbrevName + } + branches(first: $first, query: $query) { + nodes { + abbrevName + } + totalCount + } + } + } +` + export const FILE_CONTENTS_QUERY = ` query FileContentsQuery($repoName: String!, $filePath: String!, $rev: String!) { repository(name: $repoName){ @@ -769,19 +787,12 @@ query GetDirectoryContents($repoName: String!, $revision: String!, $path: String commit(rev: $revision) { tree(path: $path) { entries(first: 15) { - ... on GitBlob { - name + ...on GitBlob { path - byteSize url rawURL binary } - ... on GitTree { - name - path - isDirectory - } } } } diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts index 616e231d06b3..752f57e357ff 100644 --- a/vscode/src/context/openctx/common/branch-mentions.ts +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -1,5 +1,5 @@ import type { Mention } from '@openctx/client' -import { currentResolvedConfig, isDefined } from '@sourcegraph/cody-shared' +import { currentResolvedConfig, graphqlClient, isDefined, isError } from '@sourcegraph/cody-shared' import { getRepositoryMentions } from './get-repository-mentions' export interface BranchMentionOptions { @@ -40,6 +40,15 @@ export async function getBranchMentions(options: BranchMentionOptions): Promise< if (branchQuery?.trim()) { const query = branchQuery.toLowerCase() filteredBranches = branches.filter(branch => branch.toLowerCase().includes(query)) + + // If we have a search query but found no matches in the first 10 branches, + // try searching for more branches using the GraphQL API + if (filteredBranches.length === 0 && query.length >= 2) { + const searchResult = await searchRepositoryBranches(repoName, branchQuery, repoId, defaultBranch) + if (searchResult.length > 0) { + return searchResult + } + } } return createBranchMentionsFromData({ @@ -59,6 +68,46 @@ export interface CreateBranchMentionsOptions { branchQuery?: string } +/** + * Searches for branches in a repository using the GraphQL API when client-side filtering + * doesn't find matches in the first 10 branches. + */ +async function searchRepositoryBranches( + repoName: string, + branchQuery: string, + repoId: string, + defaultBranch?: string +): Promise { + try { + const response = await graphqlClient.getRepositoryBranches(repoName, 10, branchQuery) + + if (isError(response) || !response.repository) { + return [] + } + + const { repository } = response + const allBranches = repository.branches.nodes.map(node => node.abbrevName) + const repositoryDefaultBranch = repository.defaultBranch?.abbrevName || defaultBranch + + // Filter branches client-side with the search query + const query = branchQuery.toLowerCase() + const filteredBranches = allBranches.filter(branch => + branch.toLowerCase().includes(query) + ) + + return createBranchMentionsFromData({ + repoName, + repoId, + defaultBranch: repositoryDefaultBranch, + branches: filteredBranches, + branchQuery, + }) + } catch (error) { + // If the search fails, return empty array to fall back to client-side filtering + return [] + } +} + /** * Creates mention objects for branches with optional browse and search hint options. */ diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index d69dcdd3ea27..c5b11d1be106 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -513,6 +513,107 @@ describe('RemoteDirectoryProvider mentions', () => { }) }) + test('should search for branches beyond first 10 when no matches found in initial branches', async () => { + // Mock the resolved config + mockResolvedConfig({ + auth: { + serverEndpoint: auth.serverEndpoint, + }, + }) + + // Mock getRepositoryMentions to return only first 10 branches (none matching our query) + const { getRepositoryMentions } = await import('./common/get-repository-mentions') + vi.mocked(getRepositoryMentions).mockResolvedValue([ + { + title: 'test-repo', + providerUri: REMOTE_DIRECTORY_PROVIDER_URI, + uri: `${auth.serverEndpoint}/test-repo`, + description: ' ', + data: { + repoId: 'repo-id', + repoName: 'test-repo', + defaultBranch: 'main', + branches: [ + 'main', + 'develop', + 'feature/ui', + 'feature/api', + 'fix/bug1', + 'fix/bug2', + 'release/v1.0', + 'release/v1.1', + 'hotfix/critical', + 'docs/update', + ], + isIgnored: false, + }, + }, + ]) + + // Mock the GraphQL client to return more branches including our target + vi.spyOn(graphqlClient, 'getRepositoryBranches').mockResolvedValue({ + repository: { + id: 'repo-id', + name: 'test-repo', + defaultBranch: { abbrevName: 'main' }, + branches: { + nodes: [ + { abbrevName: 'main' }, + { abbrevName: 'develop' }, + { abbrevName: 'feature/ui' }, + { abbrevName: 'feature/api' }, + { abbrevName: 'fix/bug1' }, + { abbrevName: 'fix/bug2' }, + { abbrevName: 'release/v1.0' }, + { abbrevName: 'release/v1.1' }, + { abbrevName: 'hotfix/critical' }, + { abbrevName: 'docs/update' }, + // Additional branches beyond the first 10 + { abbrevName: 'apply-fog-order' }, + { abbrevName: 'apply-fog-fix' }, + { abbrevName: 'feature/other-logic' }, + ], + totalCount: 13, + }, + }, + }) + + const provider = createRemoteDirectoryProvider() + const mentions = await provider.mentions?.({ query: 'test-repo:@apply-fog' }, {}) + + // Should find the branches that match "apply-fog" from the extended search + expect(mentions).toHaveLength(2) + + // Check that getRepositoryMentions was called first + expect(getRepositoryMentions).toHaveBeenCalledWith('test-repo', REMOTE_DIRECTORY_PROVIDER_URI) + + // Check that GraphQL client was called to get more branches + expect(graphqlClient.getRepositoryBranches).toHaveBeenCalledWith('test-repo', 10) + + // Check that we get the matching branches from the extended search + expect(mentions?.[0]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@apply-fog-order`, + title: '@apply-fog-order', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'apply-fog-order', + }, + }) + + expect(mentions?.[1]).toEqual({ + uri: `${auth.serverEndpoint}/test-repo@apply-fog-fix`, + title: '@apply-fog-fix', + description: ' ', + data: { + repoName: 'test-repo', + repoID: 'repo-id', + branch: 'apply-fog-fix', + }, + }) + }) + test('should handle branch filtering without @ prefix', async () => { // Mock the resolved config mockResolvedConfig({ diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 7da3ffaef600..82aca0279908 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -223,7 +223,7 @@ async function getDirectoryItem( const items: Item[] = [] for (const entry of entries) { - if (entry.isDirectory || entry.binary) { + if (!entry.rawURL || entry.binary) { continue } From 56730e250de3af1648fe133c93a056952c5e974d Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 30 Jul 2025 10:05:13 +0300 Subject: [PATCH 14/27] fix regex for starting directories mentions on searches branches --- vscode/src/context/openctx/remoteDirectorySearch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 82aca0279908..8db1d42527b7 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -157,8 +157,8 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P const repoWithBranch = branchPart ? `${repoRe}@${escapeRegExp(branchPart)}` : repoRe // For root directory search, use a pattern that finds top-level directories - const filePattern = directoryPath ? `^${directoryRe}.*\\/.*` : '[^/]+\\/.*' - const query = `repo:${repoWithBranch} file:${filePattern} select:file.directory count:10` + const filePattern = directoryPath ? `^${directoryRe}.*\\/.*` : '^[^/]+/[^/]+$' + const query = `repo:${repoWithBranch} file:${filePattern} select:file.directory count:1000` const { auth: { serverEndpoint }, From ca80b62589f44af84b0f4df0baf33ea1b887dbc3 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 30 Jul 2025 11:18:51 +0300 Subject: [PATCH 15/27] skip . folders from initial folder listing Also sort directory listing by path name as it happens on online listing --- vscode/src/context/openctx/remoteDirectorySearch.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 8db1d42527b7..403f2b07120c 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -157,7 +157,7 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P const repoWithBranch = branchPart ? `${repoRe}@${escapeRegExp(branchPart)}` : repoRe // For root directory search, use a pattern that finds top-level directories - const filePattern = directoryPath ? `^${directoryRe}.*\\/.*` : '^[^/]+/[^/]+$' + const filePattern = directoryPath ? `^${directoryRe}.*\\/.*` : '^[^/]+/[^/]+$ -file:^\\.' const query = `repo:${repoWithBranch} file:${filePattern} select:file.directory count:1000` const { @@ -169,6 +169,11 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P return [] } + // sort results by file path + dataOrError.search.results.results.sort((a, b) => { + return a.file.path.localeCompare(b.file.path) + }) + return dataOrError.search.results.results .map(result => { if (result.__typename !== 'FileMatch') { From f32012fd01d9057480c9617839c26b02b6567308 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 30 Jul 2025 12:05:21 +0300 Subject: [PATCH 16/27] simplify branch mentions after proper branch searching --- .../openctx/remoteDirectorySearch.test.ts | 13 +++++++------ .../context/openctx/remoteDirectorySearch.ts | 18 ++++++------------ 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index c5b11d1be106..21c505668e5a 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -17,6 +17,7 @@ vi.mock('./common/get-repository-mentions', () => ({ mockClientCapabilities(CLIENT_CAPABILITIES_FIXTURE) const auth = { serverEndpoint: 'https://sourcegraph.com' } +const SEARCH_FILE_MATCHES = "repo:^test-repo$@feature-branch file:^[^/]+/[^/]+$ -file:^\\. select:file.directory count:1000" describe('RemoteDirectoryProvider branch parsing', () => { describe('extractRepoAndBranch', () => { @@ -204,7 +205,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Verify the correct parameters were passed to searchFileMatches expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - 'repo:^github\\.com/mrdoob/three\\.js$@e2e file:^manual.*\\/.* select:file.directory count:10' + "repo:^github\\.com/mrdoob/three\\.js$@e2e file:^manual.*\\/.* select:file.directory count:1000" ) }) @@ -261,7 +262,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Verify the branch name is used in the search query expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - 'repo:^test-repo$@feature-branch file:[^/]+\\/.* select:file.directory count:10' + SEARCH_FILE_MATCHES ) }) @@ -318,7 +319,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Verify the correct parameters were passed to searchFileMatches expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - 'repo:^test-repo$@feature-branch file:[^/]+\\/.* select:file.directory count:10' + SEARCH_FILE_MATCHES ) }) @@ -375,7 +376,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Verify the correct parameters were passed to searchFileMatches expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - 'repo:^test-repo$@dev file:^src.*\\/.* select:file.directory count:10' + 'repo:^test-repo$@dev file:^src.*\\/.* select:file.directory count:1000' ) }) @@ -588,7 +589,7 @@ describe('RemoteDirectoryProvider mentions', () => { expect(getRepositoryMentions).toHaveBeenCalledWith('test-repo', REMOTE_DIRECTORY_PROVIDER_URI) // Check that GraphQL client was called to get more branches - expect(graphqlClient.getRepositoryBranches).toHaveBeenCalledWith('test-repo', 10) + expect(graphqlClient.getRepositoryBranches).toHaveBeenCalledWith('test-repo', 10, "apply-fog") // Check that we get the matching branches from the extended search expect(mentions?.[0]).toEqual({ @@ -739,7 +740,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Should have called directory search expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - 'repo:^test-repo$ file:^src.*\\/.* select:file.directory count:10' + 'repo:^test-repo$ file:^src.*\\/.* select:file.directory count:1000' ) expect(mentions?.[0]).toEqual({ diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 403f2b07120c..f288e84f9e2c 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -95,20 +95,14 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv return await getDirectoryMentions(`${repoName}@${branchName}`, directoryPath) } - // Check if this looks like a complete branch name vs a search query - // Complete branch names typically have hyphens, slashes, underscores, or are longer - const looksLikeCompleteBranch = branchQuery.length > 6 || - branchQuery.includes('-') || - branchQuery.includes('/') || - branchQuery.includes('_') - - if (looksLikeCompleteBranch) { - // Treat as exact branch name - show directories for this branch - return await getDirectoryMentions(`${repoName}@${branchQuery}`, '') + const branchMentions = await getDirectoryBranchMentions(repoName, branchQuery) + + if (branchMentions.length > 0) { + return branchMentions } - // Short query or empty - show branch filtering/search - return await getDirectoryBranchMentions(repoName, branchQuery) + // No branch matches found - treat as exact branch name and show directories + return await getDirectoryMentions(`${repoName}@${branchQuery}`, '') } // Step 4: Directory selection/filtering From 8fe7cec390300e001db391395a1f000a8a43bb1b Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 30 Jul 2025 19:59:08 +0300 Subject: [PATCH 17/27] simplify check for remote directories branch or directory listing --- .../openctx/remoteDirectorySearch.test.ts | 10 +-- .../context/openctx/remoteDirectorySearch.ts | 71 +++---------------- 2 files changed, 14 insertions(+), 67 deletions(-) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 21c505668e5a..59ac5d14069f 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -185,7 +185,7 @@ describe('RemoteDirectoryProvider mentions', () => { const provider = createRemoteDirectoryProvider() const mentions = await provider.mentions?.( - { query: 'github.com/mrdoob/three.js@e2e/manual' }, + { query: 'github.com/mrdoob/three.js@e2e:manual' }, {} ) @@ -301,7 +301,7 @@ describe('RemoteDirectoryProvider mentions', () => { vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) const provider = createRemoteDirectoryProvider() - const mentions = await provider.mentions?.({ query: 'test-repo:@feature-branch' }, {}) + const mentions = await provider.mentions?.({ query: 'test-repo@feature-branch:' }, {}) expect(mentions).toHaveLength(1) expect(mentions?.[0]).toEqual({ @@ -358,7 +358,7 @@ describe('RemoteDirectoryProvider mentions', () => { vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) const provider = createRemoteDirectoryProvider() - const mentions = await provider.mentions?.({ query: 'test-repo:@dev/src' }, {}) + const mentions = await provider.mentions?.({ query: 'test-repo@dev:src' }, {}) expect(mentions).toHaveLength(1) expect(mentions?.[0]).toEqual({ @@ -472,7 +472,7 @@ describe('RemoteDirectoryProvider mentions', () => { ]) const provider = createRemoteDirectoryProvider() - const mentions = await provider.mentions?.({ query: 'test-repo:@feat' }, {}) + const mentions = await provider.mentions?.({ query: 'test-repo:feat' }, {}) expect(mentions).toHaveLength(2) // 2 matching branches @@ -580,7 +580,7 @@ describe('RemoteDirectoryProvider mentions', () => { }) const provider = createRemoteDirectoryProvider() - const mentions = await provider.mentions?.({ query: 'test-repo:@apply-fog' }, {}) + const mentions = await provider.mentions?.({ query: 'test-repo:apply-fog' }, {}) // Should find the branches that match "apply-fog" from the extended search expect(mentions).toHaveLength(2) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index f288e84f9e2c..23f39bb633f8 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -47,85 +47,32 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv }, async mentions({ query }) { - if (!query?.trim()) { + // Step 1: Start with showing repositories search + const trimmedQuery = query?.trim() + if (!trimmedQuery) { return await getRepositoryMentions('', REMOTE_DIRECTORY_PROVIDER_URI) } - const trimmedQuery = query.trim() - - // Step 1: Repository selection (no colon means we're still selecting repos) - if (!trimmedQuery.includes(':')) { - // Handle repo@branch format (direct branch specification) - if (trimmedQuery.includes('@')) { - const slashIndex = trimmedQuery.indexOf('/', trimmedQuery.indexOf('@')) - if (slashIndex > 0) { - // Format: repo@branch/directory - const repoWithBranch = trimmedQuery.substring(0, slashIndex) - const directoryPathPart = trimmedQuery.substring(slashIndex + 1) - return await getDirectoryMentions(repoWithBranch, directoryPathPart) - } - // Format: repo@branch (show root directories) - return await getDirectoryMentions(trimmedQuery, '') - } - // No @ symbol, still selecting repositories - return await getRepositoryMentions(trimmedQuery, REMOTE_DIRECTORY_PROVIDER_URI) - } - // Step 2: Parse repo:path format + // Step 2: Show Branch or Directory initial listing const [repoName, pathPart] = trimmedQuery.split(':', 2) - if (!repoName.trim()) { - return await getRepositoryMentions('', REMOTE_DIRECTORY_PROVIDER_URI) - } - - // Step 3: Branch selection/filtering (path starts with @) - if (pathPart?.startsWith('@')) { - const branchQuery = pathPart.substring(1) // Remove @ - - // Handle trailing colon from mention menu selection (repo:@branch:) - if (branchQuery.endsWith(':')) { - const cleanBranchName = branchQuery.slice(0, -1) - return await getDirectoryMentions(`${repoName}@${cleanBranchName}`, '') - } - - // Handle branch/directory format (repo:@branch/directory) - const slashIndex = branchQuery.indexOf('/') - if (slashIndex > 0) { - const branchName = branchQuery.substring(0, slashIndex) - const directoryPath = branchQuery.substring(slashIndex + 1) - return await getDirectoryMentions(`${repoName}@${branchName}`, directoryPath) - } - - const branchMentions = await getDirectoryBranchMentions(repoName, branchQuery) - - if (branchMentions.length > 0) { - return branchMentions - } - - // No branch matches found - treat as exact branch name and show directories - return await getDirectoryMentions(`${repoName}@${branchQuery}`, '') - } - - // Step 4: Directory selection/filtering if (!pathPart?.trim()) { // Empty path after colon - check if repo has branch specified if (repoName.includes('@')) { // repo@branch: - show directories for this branch return await getDirectoryMentions(repoName, '') } + // repo: - show branch selection return await getDirectoryBranchMentions(repoName) } - // Step 5: Handle repo:query - could be branch filtering or directory search - // First try branch filtering (this allows filtering without @ prefix) - const branchMentions = await getDirectoryBranchMentions(repoName, pathPart.trim()) - - // If we found matching branches, return them - if (branchMentions.length > 0) { - return branchMentions + // Step 3: If we have a path without an @, search for branches + if (!repoName.includes('@')) { + return await getDirectoryBranchMentions(repoName, pathPart.trim()) } - // No matching branches found, treat as directory search + // Step 4: No matching branches found, treat as directory search return await getDirectoryMentions(repoName, pathPart.trim()) }, From 1841de6177f97750b5b9d4ac0231ae5050ff3047 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 30 Jul 2025 22:02:40 +0300 Subject: [PATCH 18/27] allow remote file to search for branches --- vscode/src/context/openctx/remoteFileSearch.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vscode/src/context/openctx/remoteFileSearch.ts b/vscode/src/context/openctx/remoteFileSearch.ts index 2f6ea03260f6..cac436717913 100644 --- a/vscode/src/context/openctx/remoteFileSearch.ts +++ b/vscode/src/context/openctx/remoteFileSearch.ts @@ -40,6 +40,10 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider const [repoNamePart, branch] = repoName.split('@') return await getFileMentions(repoNamePart, filePath.trim(), branch) } + + if (!repoName.includes('@')) { + return await getFileBranchMentions(repoName, filePath.trim()) + } return await getFileBranchMentions(repoName) }, From 2b43819a01d584fa863e34ae6e07f62b17d2b043 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 30 Jul 2025 22:15:34 +0300 Subject: [PATCH 19/27] test fixes --- .../context/openctx/common/branch-mentions.ts | 11 +- .../openctx/remoteDirectorySearch.test.ts | 111 +++--------------- .../context/openctx/remoteDirectorySearch.ts | 1 - 3 files changed, 22 insertions(+), 101 deletions(-) diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts index 752f57e357ff..7d219315b77e 100644 --- a/vscode/src/context/openctx/common/branch-mentions.ts +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -44,7 +44,12 @@ export async function getBranchMentions(options: BranchMentionOptions): Promise< // If we have a search query but found no matches in the first 10 branches, // try searching for more branches using the GraphQL API if (filteredBranches.length === 0 && query.length >= 2) { - const searchResult = await searchRepositoryBranches(repoName, branchQuery, repoId, defaultBranch) + const searchResult = await searchRepositoryBranches( + repoName, + branchQuery, + repoId, + defaultBranch + ) if (searchResult.length > 0) { return searchResult } @@ -91,9 +96,7 @@ async function searchRepositoryBranches( // Filter branches client-side with the search query const query = branchQuery.toLowerCase() - const filteredBranches = allBranches.filter(branch => - branch.toLowerCase().includes(query) - ) + const filteredBranches = allBranches.filter(branch => branch.toLowerCase().includes(query)) return createBranchMentionsFromData({ repoName, diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 59ac5d14069f..021e11c1db14 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -17,7 +17,8 @@ vi.mock('./common/get-repository-mentions', () => ({ mockClientCapabilities(CLIENT_CAPABILITIES_FIXTURE) const auth = { serverEndpoint: 'https://sourcegraph.com' } -const SEARCH_FILE_MATCHES = "repo:^test-repo$@feature-branch file:^[^/]+/[^/]+$ -file:^\\. select:file.directory count:1000" +const SEARCH_FILE_MATCHES = + 'repo:^test-repo$@feature-branch file:^[^/]+/[^/]+$ -file:^\\. select:file.directory count:1000' describe('RemoteDirectoryProvider branch parsing', () => { describe('extractRepoAndBranch', () => { @@ -205,7 +206,7 @@ describe('RemoteDirectoryProvider mentions', () => { // Verify the correct parameters were passed to searchFileMatches expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - "repo:^github\\.com/mrdoob/three\\.js$@e2e file:^manual.*\\/.* select:file.directory count:1000" + 'repo:^github\\.com/mrdoob/three\\.js$@e2e file:^manual.*\\/.* select:file.directory count:1000' ) }) @@ -261,9 +262,7 @@ describe('RemoteDirectoryProvider mentions', () => { }) // Verify the branch name is used in the search query - expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - SEARCH_FILE_MATCHES - ) + expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith(SEARCH_FILE_MATCHES) }) test('should handle repo:@branch format (colon followed by @branch)', async () => { @@ -318,9 +317,7 @@ describe('RemoteDirectoryProvider mentions', () => { }) // Verify the correct parameters were passed to searchFileMatches - expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - SEARCH_FILE_MATCHES - ) + expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith(SEARCH_FILE_MATCHES) }) test('should handle repo:@branch/directory format', async () => { @@ -589,7 +586,7 @@ describe('RemoteDirectoryProvider mentions', () => { expect(getRepositoryMentions).toHaveBeenCalledWith('test-repo', REMOTE_DIRECTORY_PROVIDER_URI) // Check that GraphQL client was called to get more branches - expect(graphqlClient.getRepositoryBranches).toHaveBeenCalledWith('test-repo', 10, "apply-fog") + expect(graphqlClient.getRepositoryBranches).toHaveBeenCalledWith('test-repo', 10, 'apply-fog') // Check that we get the matching branches from the extended search expect(mentions?.[0]).toEqual({ @@ -679,83 +676,6 @@ describe('RemoteDirectoryProvider mentions', () => { }, }) }) - - test('should fallback to directory search when no branches match', async () => { - // Mock the resolved config - mockResolvedConfig({ - auth: { - serverEndpoint: auth.serverEndpoint, - }, - }) - - // Mock getRepositoryMentions to return branch data (no branches matching 'src') - const { getRepositoryMentions } = await import('./common/get-repository-mentions') - vi.mocked(getRepositoryMentions).mockResolvedValue([ - { - title: 'test-repo', - providerUri: REMOTE_DIRECTORY_PROVIDER_URI, - uri: `${auth.serverEndpoint}/test-repo`, - description: ' ', - data: { - repoId: 'repo-id', - repoName: 'test-repo', - defaultBranch: 'main', - branches: ['main', 'develop', 'feature-branch'], - isIgnored: false, - }, - }, - ]) - - // Mock directory search - const mockSearchFileMatches = { - search: { - results: { - results: [ - { - __typename: 'FileMatch', - repository: { - id: 'repo-id', - name: 'test-repo', - }, - file: { - url: '/test-repo/-/tree/src', - path: 'src', - commit: { - oid: 'abc123', - }, - }, - }, - ], - }, - }, - } - - vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) - - const provider = createRemoteDirectoryProvider() - // Test with 'src' - no branches match, should fallback to directory search - const mentions = await provider.mentions?.({ query: 'test-repo:src' }, {}) - - expect(mentions).toHaveLength(1) // 1 directory result - - // Should have called directory search - expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith( - 'repo:^test-repo$ file:^src.*\\/.* select:file.directory count:1000' - ) - - expect(mentions?.[0]).toEqual({ - uri: `${auth.serverEndpoint}/test-repo/-/tree/src`, - title: 'src', - description: ' ', - data: { - repoName: 'test-repo', - repoID: 'repo-id', - rev: 'abc123', - directoryPath: 'src', - branch: undefined, - }, - }) - }) }) describe('RemoteDirectoryProvider directory contents', () => { @@ -806,16 +726,15 @@ describe('RemoteDirectoryProvider directory contents', () => { vi.spyOn(graphqlClient, 'getDirectoryContents').mockResolvedValue(mockDirectoryContents) // Mock fetchContentFromRawURL to return content for each file - vi.spyOn(graphqlClient, 'fetchContentFromRawURL') - .mockImplementation(async (rawURL: string) => { - if (rawURL.includes('file1.ts')) { - return 'const foo = "bar";' - } - if (rawURL.includes('file2.js')) { - return 'console.log("hello");' - } - return new Error('File not found') - }) + vi.spyOn(graphqlClient, 'fetchContentFromRawURL').mockImplementation(async (rawURL: string) => { + if (rawURL.includes('file1.ts')) { + return 'const foo = "bar";' + } + if (rawURL.includes('file2.js')) { + return 'console.log("hello");' + } + return new Error('File not found') + }) const provider = createRemoteDirectoryProvider() const items = await provider.items?.( diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 23f39bb633f8..1766e068d0c7 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -53,7 +53,6 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv return await getRepositoryMentions('', REMOTE_DIRECTORY_PROVIDER_URI) } - // Step 2: Show Branch or Directory initial listing const [repoName, pathPart] = trimmedQuery.split(':', 2) if (!pathPart?.trim()) { From 9be17a4ede50728854db6f50d66c85e7907bb6c1 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 30 Jul 2025 23:12:17 +0300 Subject: [PATCH 20/27] fix mention menu tooltip height when branch name is long --- .../src/mentions/mentionMenu/MentionMenu.branch.test.ts | 2 +- .../src/mentions/mentionMenu/MentionMenu.tsx | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts index 104b6c6257ff..0b1e72e99427 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.branch.test.ts @@ -168,6 +168,6 @@ describe('MentionMenu branch selection', () => { const mentionQuery = { text: 'test-repo:', provider: REMOTE_DIRECTORY_PROVIDER_URI } const result = getBranchHelpText(items!, mentionQuery) - expect(result).toBe('* Select or @ search for a specific branch') + expect(result).toBe('* Select or search for a specific branch') }) }) diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx index 811553861a61..eef7ca5caf76 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenu.tsx @@ -310,7 +310,8 @@ export const MentionMenu: FunctionComponent< className={clsx( COMMAND_ROW_CLASS_NAME, COMMAND_ROW_TEXT_CLASS_NAME, - 'tw-bg-accent' + 'tw-bg-accent', + '!tw-h-auto' )} > {getBranchHelpText(data.items, mentionQuery)} @@ -373,7 +374,7 @@ export function getBranchHelpText( if (repoName && !directoryPath) { // Check if this is a branch mention (title starts with @) if (firstItem.title?.startsWith('@')) { - return '* Select or @ search for a specific branch' + return '* Select or search for a specific branch' } } @@ -453,9 +454,7 @@ function getItemsHeading( if (parentItem.id === REMOTE_DIRECTORY_PROVIDER_URI) { return (
-
- {getRemoteDirectoryHeading(mentionQuery.text)} -
+
{getRemoteDirectoryHeading(mentionQuery.text)}
) } From 57bffd7d4eee69eeb73d122a5203ed11aeeb54cc Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 31 Jul 2025 09:00:50 +0300 Subject: [PATCH 21/27] removed `getFileBranchMentions` fallback to file search - not needed for this use case as mentioned in review --- vscode/src/context/openctx/remoteFileSearch.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/vscode/src/context/openctx/remoteFileSearch.ts b/vscode/src/context/openctx/remoteFileSearch.ts index cac436717913..68292af2c1d8 100644 --- a/vscode/src/context/openctx/remoteFileSearch.ts +++ b/vscode/src/context/openctx/remoteFileSearch.ts @@ -110,11 +110,6 @@ async function getFileBranchMentions(repoName: string, branchQuery?: string): Pr branchQuery, }) - // If no branch mentions found, fallback to file search - if (branchMentions.length === 0) { - return await getFileMentions(repoName, '') - } - return branchMentions } From 29ce80abf03f3de465ebce1fe272961b18bdca5f Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 31 Jul 2025 10:45:18 +0300 Subject: [PATCH 22/27] simplify code by getting rid of need for extractBranchAndRepo --- .../openctx/remoteDirectorySearch.test.ts | 202 +----------------- .../context/openctx/remoteDirectorySearch.ts | 27 +-- .../src/context/openctx/remoteFileSearch.ts | 14 +- 3 files changed, 12 insertions(+), 231 deletions(-) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 021e11c1db14..c0bbcd4cc333 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -6,7 +6,7 @@ import { mockResolvedConfig, } from '@sourcegraph/cody-shared' import { describe, expect, test, vi } from 'vitest' -import { createRemoteDirectoryProvider, extractRepoAndBranch } from './remoteDirectorySearch' +import { createRemoteDirectoryProvider } from './remoteDirectorySearch' // Mock the getRepositoryMentions function vi.mock('./common/get-repository-mentions', () => ({ @@ -20,82 +20,6 @@ const auth = { serverEndpoint: 'https://sourcegraph.com' } const SEARCH_FILE_MATCHES = 'repo:^test-repo$@feature-branch file:^[^/]+/[^/]+$ -file:^\\. select:file.directory count:1000' -describe('RemoteDirectoryProvider branch parsing', () => { - describe('extractRepoAndBranch', () => { - test('should extract repo name without branch', () => { - const [repo, branch] = extractRepoAndBranch('test-repo') - expect(repo).toBe('test-repo') - expect(branch).toBeUndefined() - }) - - test('should extract repo name with branch', () => { - const [repo, branch] = extractRepoAndBranch('test-repo@feature-branch') - expect(repo).toBe('test-repo') - expect(branch).toBe('feature-branch') - }) - - test('should handle repo:directory format without branch', () => { - const [repo, branch] = extractRepoAndBranch('test-repo:src/components') - expect(repo).toBe('test-repo') - expect(branch).toBeUndefined() - }) - - test('should handle repo@branch:directory format', () => { - const [repo, branch] = extractRepoAndBranch('test-repo@dev:src/components') - expect(repo).toBe('test-repo') - expect(branch).toBe('dev') - }) - - test('should handle complex branch names', () => { - const [repo, branch] = extractRepoAndBranch('my-repo@feature/fix-123') - expect(repo).toBe('my-repo') - expect(branch).toBe('feature/fix-123') - }) - - test('should handle empty string', () => { - const [repo, branch] = extractRepoAndBranch('') - expect(repo).toBe('') - expect(branch).toBeUndefined() - }) - - test('should handle @ at the end', () => { - const [repo, branch] = extractRepoAndBranch('test-repo@') - expect(repo).toBe('test-repo') - expect(branch).toBe('') - }) - - test('should extract github.com/mrdoob/three.js@dev correctly', () => { - const [repo, branch] = extractRepoAndBranch('github.com/mrdoob/three.js@dev') - expect(repo).toBe('github.com/mrdoob/three.js') - expect(branch).toBe('dev') - }) - - test('should handle branch names with slashes', () => { - const [repo, branch] = extractRepoAndBranch('test-repo@fix/feature') - expect(repo).toBe('test-repo') - expect(branch).toBe('fix/feature') - }) - - test('should handle complex branch names with multiple special characters', () => { - const [repo, branch] = extractRepoAndBranch('my-repo@feature/fix-123_test') - expect(repo).toBe('my-repo') - expect(branch).toBe('feature/fix-123_test') - }) - - test('should handle branch names with dots', () => { - const [repo, branch] = extractRepoAndBranch('test-repo@release/v1.2.3') - expect(repo).toBe('test-repo') - expect(branch).toBe('release/v1.2.3') - }) - - test('should handle repo:directory@branch format with special characters', () => { - const [repo, branch] = extractRepoAndBranch('test-repo@feature/fix-123:src/components') - expect(repo).toBe('test-repo') - expect(branch).toBe('feature/fix-123') - }) - }) -}) - describe('RemoteDirectoryProvider mentions', () => { test('should handle branch selection for root directory search', async () => { // Mock the resolved config @@ -265,62 +189,7 @@ describe('RemoteDirectoryProvider mentions', () => { expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith(SEARCH_FILE_MATCHES) }) - test('should handle repo:@branch format (colon followed by @branch)', async () => { - // Mock the resolved config - mockResolvedConfig({ - auth: { - serverEndpoint: auth.serverEndpoint, - }, - }) - - // Mock the graphqlClient.searchFileMatches method - const mockSearchFileMatches = { - search: { - results: { - results: [ - { - __typename: 'FileMatch', - repository: { - id: 'repo-id', - name: 'test-repo', - }, - file: { - url: '/test-repo@feature-branch/-/tree/docs', - path: 'docs', - commit: { - oid: 'abc123', - }, - }, - }, - ], - }, - }, - } - - vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) - - const provider = createRemoteDirectoryProvider() - const mentions = await provider.mentions?.({ query: 'test-repo@feature-branch:' }, {}) - - expect(mentions).toHaveLength(1) - expect(mentions?.[0]).toEqual({ - uri: `${auth.serverEndpoint}/test-repo@feature-branch/-/tree/docs`, - title: 'docs', - description: ' ', - data: { - repoName: 'test-repo', - repoID: 'repo-id', - rev: 'abc123', - directoryPath: 'docs', - branch: 'feature-branch', - }, - }) - - // Verify the correct parameters were passed to searchFileMatches - expect(graphqlClient.searchFileMatches).toHaveBeenCalledWith(SEARCH_FILE_MATCHES) - }) - - test('should handle repo:@branch/directory format', async () => { + test('should handle repo@branch:/directory format', async () => { // Mock the resolved config mockResolvedConfig({ auth: { @@ -436,7 +305,7 @@ describe('RemoteDirectoryProvider mentions', () => { }) }) - test('should handle fuzzy branch search when user types repo:@query', async () => { + test('should handle fuzzy branch search when user types repo:query', async () => { // Mock the resolved config mockResolvedConfig({ auth: { @@ -611,71 +480,6 @@ describe('RemoteDirectoryProvider mentions', () => { }, }) }) - - test('should handle branch filtering without @ prefix', async () => { - // Mock the resolved config - mockResolvedConfig({ - auth: { - serverEndpoint: auth.serverEndpoint, - }, - }) - - // Mock getRepositoryMentions to return branch data - const { getRepositoryMentions } = await import('./common/get-repository-mentions') - vi.mocked(getRepositoryMentions).mockResolvedValue([ - { - title: 'test-repo', - providerUri: REMOTE_DIRECTORY_PROVIDER_URI, - uri: `${auth.serverEndpoint}/test-repo`, - description: ' ', - data: { - repoId: 'repo-id', - repoName: 'test-repo', - defaultBranch: 'main', - branches: [ - 'main', - 'feature/search-improvement', - 'feature/search-ui', - 'fix/search-bug', - 'develop', - ], - isIgnored: false, - }, - }, - ]) - - const provider = createRemoteDirectoryProvider() - // Test filtering branches without @ prefix - should match branches first - const mentions = await provider.mentions?.({ query: 'test-repo:feat' }, {}) - - expect(mentions).toHaveLength(2) // 2 matching branches - - // Check that getRepositoryMentions was called - expect(getRepositoryMentions).toHaveBeenCalledWith('test-repo', REMOTE_DIRECTORY_PROVIDER_URI) - - // Check that we get the matching branches (filtered by 'feat') - expect(mentions?.[0]).toEqual({ - uri: `${auth.serverEndpoint}/test-repo@feature/search-improvement`, - title: '@feature/search-improvement', - description: ' ', - data: { - repoName: 'test-repo', - repoID: 'repo-id', - branch: 'feature/search-improvement', - }, - }) - - expect(mentions?.[1]).toEqual({ - uri: `${auth.serverEndpoint}/test-repo@feature/search-ui`, - title: '@feature/search-ui', - description: ' ', - data: { - repoName: 'test-repo', - repoID: 'repo-id', - branch: 'feature/search-ui', - }, - }) - }) }) describe('RemoteDirectoryProvider directory contents', () => { diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 1766e068d0c7..ee730fcd42f7 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -8,31 +8,6 @@ import { escapeRegExp } from './remoteFileSearch' import type { OpenCtxProvider } from './types' -/** - * Extracts repo name and optional branch from a string. - * Supports formats: "repo@branch", "repo", or "repo:directory@branch" - */ -export function extractRepoAndBranch(input: string): [string, string | undefined] { - // Handle case where input contains a colon (repo:directory@branch) - const colonIndex = input.indexOf(':') - if (colonIndex !== -1) { - const repoPart = input.substring(0, colonIndex) - const atIndex = repoPart.indexOf('@') - if (atIndex !== -1) { - return [repoPart.substring(0, atIndex), repoPart.substring(atIndex + 1)] - } - return [repoPart, undefined] - } - - // Handle simple case: repo@branch or repo - const atIndex = input.indexOf('@') - if (atIndex !== -1) { - return [input.substring(0, atIndex), input.substring(atIndex + 1)] - } - - return [input, undefined] -} - const RemoteDirectoryProvider = createRemoteDirectoryProvider() export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProvider { @@ -91,7 +66,7 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv async function getDirectoryMentions(repoName: string, directoryPath?: string): Promise { // Parse repo name and optional branch (format: repo@branch or repo:directory@branch) - const [repoNamePart, branchPart] = extractRepoAndBranch(repoName) + const [repoNamePart, branchPart] = repoName.split('@') const repoRe = `^${escapeRegExp(repoNamePart)}$` const directoryRe = directoryPath ? escapeRegExp(directoryPath) : '' const repoWithBranch = branchPart ? `${repoRe}@${escapeRegExp(branchPart)}` : repoRe diff --git a/vscode/src/context/openctx/remoteFileSearch.ts b/vscode/src/context/openctx/remoteFileSearch.ts index 68292af2c1d8..fc01d67c701d 100644 --- a/vscode/src/context/openctx/remoteFileSearch.ts +++ b/vscode/src/context/openctx/remoteFileSearch.ts @@ -27,10 +27,15 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider }, async mentions({ query }) { - const [repoName, filePath] = query?.split(':') || [] + const trimmedQuery = query?.trim() + if (!trimmedQuery) { + return await getRepositoryMentions('', REMOTE_FILE_PROVIDER_URI) + } + + const [repoName, filePath] = trimmedQuery.split(':') || [] - if (!query?.includes(':') || !repoName.trim()) { - return await getRepositoryMentions(query?.trim() ?? '', REMOTE_FILE_PROVIDER_URI) + if (!repoName.includes('@')) { + return await getFileBranchMentions(repoName, filePath.trim()) } // Check if we should show branch suggestions for this repository @@ -41,9 +46,6 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider return await getFileMentions(repoNamePart, filePath.trim(), branch) } - if (!repoName.includes('@')) { - return await getFileBranchMentions(repoName, filePath.trim()) - } return await getFileBranchMentions(repoName) }, From c927aaa3c5f825379d66e60a5c76c104d3fb6840 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 31 Jul 2025 16:27:48 +0300 Subject: [PATCH 23/27] simplify and bring mentions in sync between remoteFile and remoteDirectory mentions --- .../openctx/remoteDirectorySearch.test.ts | 4 +- .../context/openctx/remoteDirectorySearch.ts | 48 ++++++++----------- .../src/context/openctx/remoteFileSearch.ts | 16 +++---- 3 files changed, 28 insertions(+), 40 deletions(-) diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index c0bbcd4cc333..305f216320bf 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -56,7 +56,7 @@ describe('RemoteDirectoryProvider mentions', () => { vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) const provider = createRemoteDirectoryProvider() - const mentions = await provider.mentions?.({ query: 'github.com/mrdoob/three.js@dev' }, {}) + const mentions = await provider.mentions?.({ query: 'github.com/mrdoob/three.js@dev:' }, {}) expect(mentions).toHaveLength(1) @@ -169,7 +169,7 @@ describe('RemoteDirectoryProvider mentions', () => { vi.spyOn(graphqlClient, 'searchFileMatches').mockResolvedValue(mockSearchFileMatches) const provider = createRemoteDirectoryProvider() - const mentions = await provider.mentions?.({ query: 'test-repo@feature-branch' }, {}) + const mentions = await provider.mentions?.({ query: 'test-repo@feature-branch:' }, {}) expect(mentions).toHaveLength(1) expect(mentions?.[0]).toEqual({ diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index ee730fcd42f7..3b8f73371e02 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -23,31 +23,21 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv async mentions({ query }) { // Step 1: Start with showing repositories search - const trimmedQuery = query?.trim() - if (!trimmedQuery) { - return await getRepositoryMentions('', REMOTE_DIRECTORY_PROVIDER_URI) + const trimmedQuery = query?.trim() ?? '' + if (!trimmedQuery.includes(':')) { + return await getRepositoryMentions(trimmedQuery, REMOTE_DIRECTORY_PROVIDER_URI) } - // Step 2: Show Branch or Directory initial listing - const [repoName, pathPart] = trimmedQuery.split(':', 2) - if (!pathPart?.trim()) { - // Empty path after colon - check if repo has branch specified - if (repoName.includes('@')) { - // repo@branch: - show directories for this branch - return await getDirectoryMentions(repoName, '') - } - - // repo: - show branch selection - return await getDirectoryBranchMentions(repoName) - } - - // Step 3: If we have a path without an @, search for branches + // Step 2: Show Branch listing and allow searching + const [repoName, directoryPath] = trimmedQuery.split(':') if (!repoName.includes('@')) { - return await getDirectoryBranchMentions(repoName, pathPart.trim()) + return await getDirectoryBranchMentions(repoName, directoryPath?.trim()) } - // Step 4: No matching branches found, treat as directory search - return await getDirectoryMentions(repoName, pathPart.trim()) + // This is "repo@branch:" - show file listing for this branch + // Step 3: branch found, treat as directory search + const [repoNamePart, branch] = repoName.split('@') + return await getDirectoryMentions(repoNamePart, directoryPath?.trim(), branch) }, async items({ mention, message }) { @@ -64,14 +54,16 @@ export function createRemoteDirectoryProvider(customTitle?: string): OpenCtxProv } } -async function getDirectoryMentions(repoName: string, directoryPath?: string): Promise { - // Parse repo name and optional branch (format: repo@branch or repo:directory@branch) - const [repoNamePart, branchPart] = repoName.split('@') - const repoRe = `^${escapeRegExp(repoNamePart)}$` +async function getDirectoryMentions( + repoName: string, + directoryPath?: string, + branch?: string +): Promise { + const repoRe = `^${escapeRegExp(repoName)}$` const directoryRe = directoryPath ? escapeRegExp(directoryPath) : '' - const repoWithBranch = branchPart ? `${repoRe}@${escapeRegExp(branchPart)}` : repoRe + const repoWithBranch = branch ? `${repoRe}@${branch}` : repoRe - // For root directory search, use a pattern that finds top-level directories + // For root directory search, use a pattern that finds top-level directories without .directories const filePattern = directoryPath ? `^${directoryRe}.*\\/.*` : '^[^/]+/[^/]+$ -file:^\\.' const query = `repo:${repoWithBranch} file:${filePattern} select:file.directory count:1000` @@ -97,7 +89,7 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P // Construct URL with branch information if available const baseUrl = `${serverEndpoint.replace(/\/$/, '')}/${result.repository.name}` - const branchUrl = branchPart ? `${baseUrl}@${branchPart}` : baseUrl + const branchUrl = branch ? `${baseUrl}@${branch}` : baseUrl const url = `${branchUrl}/-/tree/${result.file.path}` return { @@ -109,7 +101,7 @@ async function getDirectoryMentions(repoName: string, directoryPath?: string): P repoID: result.repository.id, rev: result.file.commit.oid, directoryPath: result.file.path, - branch: branchPart, + branch: branch, }, } satisfies Mention }) diff --git a/vscode/src/context/openctx/remoteFileSearch.ts b/vscode/src/context/openctx/remoteFileSearch.ts index fc01d67c701d..2403905b32ef 100644 --- a/vscode/src/context/openctx/remoteFileSearch.ts +++ b/vscode/src/context/openctx/remoteFileSearch.ts @@ -27,17 +27,13 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider }, async mentions({ query }) { - const trimmedQuery = query?.trim() - if (!trimmedQuery) { - return await getRepositoryMentions('', REMOTE_FILE_PROVIDER_URI) + const trimmedQuery = query?.trim() ?? '' + if (!trimmedQuery.includes(':')) { + return await getRepositoryMentions(trimmedQuery, REMOTE_FILE_PROVIDER_URI) } const [repoName, filePath] = trimmedQuery.split(':') || [] - if (!repoName.includes('@')) { - return await getFileBranchMentions(repoName, filePath.trim()) - } - // Check if we should show branch suggestions for this repository // Check if repoName contains a branch (repo@branch format from mention menu) if (repoName.includes('@')) { @@ -46,7 +42,7 @@ export function createRemoteFileProvider(customTitle?: string): OpenCtxProvider return await getFileMentions(repoNamePart, filePath.trim(), branch) } - return await getFileBranchMentions(repoName) + return await getFileBranchMentions(repoName, filePath?.trim()) }, async items({ mention }) { @@ -70,8 +66,8 @@ async function getFileMentions( ): Promise { const repoRe = `^${escapeRegExp(repoName)}$` const fileRe = filePath ? escapeRegExp(filePath) : '^.*$' - const branchPart = branch ? `@${escapeRegExp(branch)}` : '' - const query = `repo:${repoRe}${branchPart} file:${fileRe} type:file count:10` + const repoWithBranch = branch ? `${repoRe}@${branch}` : repoRe + const query = `repo:${repoWithBranch} file:${fileRe} type:file count:10` const { auth } = await currentResolvedConfig() const dataOrError = await graphqlClient.searchFileMatches(query) From 4d9016f8c1a6090667f431c64bbb121f2d5b0446 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 31 Jul 2025 17:40:06 +0300 Subject: [PATCH 24/27] remove needless try catch and clarify why we have empty description --- .../context/openctx/common/branch-mentions.ts | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts index 7d219315b77e..fae875623b65 100644 --- a/vscode/src/context/openctx/common/branch-mentions.ts +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -83,32 +83,27 @@ async function searchRepositoryBranches( repoId: string, defaultBranch?: string ): Promise { - try { - const response = await graphqlClient.getRepositoryBranches(repoName, 10, branchQuery) + const response = await graphqlClient.getRepositoryBranches(repoName, 10, branchQuery) - if (isError(response) || !response.repository) { - return [] - } - - const { repository } = response - const allBranches = repository.branches.nodes.map(node => node.abbrevName) - const repositoryDefaultBranch = repository.defaultBranch?.abbrevName || defaultBranch - - // Filter branches client-side with the search query - const query = branchQuery.toLowerCase() - const filteredBranches = allBranches.filter(branch => branch.toLowerCase().includes(query)) - - return createBranchMentionsFromData({ - repoName, - repoId, - defaultBranch: repositoryDefaultBranch, - branches: filteredBranches, - branchQuery, - }) - } catch (error) { - // If the search fails, return empty array to fall back to client-side filtering + if (isError(response) || !response.repository) { return [] } + + const { repository } = response + const allBranches = repository.branches.nodes.map(node => node.abbrevName) + const repositoryDefaultBranch = repository.defaultBranch?.abbrevName || defaultBranch + + // Filter branches client-side with the search query + const query = branchQuery.toLowerCase() + const filteredBranches = allBranches.filter(branch => branch.toLowerCase().includes(query)) + + return createBranchMentionsFromData({ + repoName, + repoId, + defaultBranch: repositoryDefaultBranch, + branches: filteredBranches, + branchQuery, + }) } /** @@ -145,6 +140,7 @@ export async function createBranchMentionsFromData( mentions.push({ uri: `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${branch}`, title: `@${branch}`, + // needs to be a space to avoid showing the URL in the menu for branches description: ' ', data: { repoName, From 75ef5761545f1db79a1996a1947e55b05e776518 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 31 Jul 2025 17:47:01 +0300 Subject: [PATCH 25/27] removed `parsedRemoteQuery` logic that ended up not being used --- .../context/openctx/common/branch-mentions.ts | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts index fae875623b65..9c758d4ab082 100644 --- a/vscode/src/context/openctx/common/branch-mentions.ts +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -153,64 +153,3 @@ export async function createBranchMentionsFromData( return mentions.filter(isDefined) } - -/** - * Parses a query string to extract repository name, branch, and path components. - * Supports formats like: - * - "repo" -> { repoName: "repo" } - * - "repo:" -> { repoName: "repo", showBranches: true } - * - "repo:@" -> { repoName: "repo", branchSearch: true } - * - "repo:@branch" -> { repoName: "repo", branch: "branch" } - * - "repo:@branch:path" -> { repoName: "repo", branch: "branch", path: "path" } - * - "repo:path" -> { repoName: "repo", path: "path" } - */ -export interface ParsedQuery { - repoName: string - showBranches?: boolean - branchSearch?: boolean - branch?: string - path?: string -} - -export function parseRemoteQuery(query: string): ParsedQuery | null { - if (!query || !query.includes(':')) { - return query ? { repoName: query.trim() } : null - } - - const parts = query.split(':') - const repoName = parts[0]?.trim() - - if (!repoName) { - return null - } - - // If just "repo:", show branches - if (parts.length === 2 && !parts[1]?.trim()) { - return { repoName, showBranches: true } - } - - const secondPart = parts[1]?.trim() ?? '' - - // Handle branch selection: "repo:@" or "repo:@branch" - if (secondPart.startsWith('@')) { - const branchQuery = secondPart.substring(1) // Remove @ - - // If just "repo:@", show branch search - if (!branchQuery) { - return { repoName, branchSearch: true } - } - - // If "repo:@branch" with no path part, return branch - if (parts.length === 2) { - return { repoName, branch: branchQuery } - } - - // If "repo:@branch:path", return branch and path - const path = parts.slice(2).join(':').trim() - return { repoName, branch: branchQuery, path } - } - - // Default case: "repo:path" - const path = parts.slice(1).join(':').trim() - return { repoName, path } -} From 8d5a4cf3e5fa9fc59bb74f166f52acd451f50fa3 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 31 Jul 2025 18:39:38 +0300 Subject: [PATCH 26/27] parallel fetch of directory items --- .../context/openctx/common/branch-mentions.ts | 2 +- .../openctx/remoteDirectorySearch.test.ts | 11 ---- .../context/openctx/remoteDirectorySearch.ts | 55 +++++++++++-------- 3 files changed, 33 insertions(+), 35 deletions(-) diff --git a/vscode/src/context/openctx/common/branch-mentions.ts b/vscode/src/context/openctx/common/branch-mentions.ts index 9c758d4ab082..faec62d8f8b1 100644 --- a/vscode/src/context/openctx/common/branch-mentions.ts +++ b/vscode/src/context/openctx/common/branch-mentions.ts @@ -27,7 +27,7 @@ export async function getBranchMentions(options: BranchMentionOptions): Promise< } const branches = (repoMention.data.branches as string[]) || [] - const defaultBranch = repoMention.data.defaultBranch as string | undefined + const defaultBranch = (repoMention.data.defaultBranch as string) || '' const repoId = repoMention.data.repoId as string // If no branch info available, return empty diff --git a/vscode/src/context/openctx/remoteDirectorySearch.test.ts b/vscode/src/context/openctx/remoteDirectorySearch.test.ts index 305f216320bf..42cb48b42d49 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.test.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.test.ts @@ -367,17 +367,6 @@ describe('RemoteDirectoryProvider mentions', () => { branch: 'feature/search-ui', }, }) - - expect(mentions?.[1]).toEqual({ - uri: `${auth.serverEndpoint}/test-repo@feature/search-ui`, - title: '@feature/search-ui', - description: ' ', - data: { - repoName: 'test-repo', - repoID: 'repo-id', - branch: 'feature/search-ui', - }, - }) }) test('should search for branches beyond first 10 when no matches found in initial branches', async () => { diff --git a/vscode/src/context/openctx/remoteDirectorySearch.ts b/vscode/src/context/openctx/remoteDirectorySearch.ts index 3b8f73371e02..db9d9b484fa5 100644 --- a/vscode/src/context/openctx/remoteDirectorySearch.ts +++ b/vscode/src/context/openctx/remoteDirectorySearch.ts @@ -133,31 +133,40 @@ async function getDirectoryItem( auth: { serverEndpoint }, } = await currentResolvedConfig() - const items: Item[] = [] - for (const entry of entries) { - if (!entry.rawURL || entry.binary) { - continue - } - - let content = '' - const rawContent = await graphqlClient.fetchContentFromRawURL(entry.rawURL) - if (!isError(rawContent)) { - content = rawContent + // Filter out binary files and entries without rawURL + const validEntries = entries.filter(entry => entry.rawURL && !entry.binary) + + // Fetch content in parallel for better performance + const contentPromises = validEntries.map(async entry => { + try { + const content = await graphqlClient.fetchContentFromRawURL(entry.rawURL!) + if (isError(content)) { + console.error(`Failed to fetch content for ${entry.path}:`, content) + return null + } + return { + entry, + content, + } + } catch (error) { + console.error(`Error fetching content for ${entry.path}:`, error) + return null } + }) - if (content) { - items.push({ - url: revision - ? `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${revision}/-/blob/${entry.path}` - : `${serverEndpoint.replace(/\/$/, '')}${entry.url}`, - title: entry.path, - ai: { - content, - }, - }) - } - } - return items + const results = await Promise.all(contentPromises) + + return results + .filter(result => result !== null) + .map(({ entry, content }) => ({ + url: revision + ? `${serverEndpoint.replace(/\/$/, '')}/${repoName}@${revision}/-/blob/${entry.path}` + : `${serverEndpoint.replace(/\/$/, '')}${entry.url}`, + title: entry.path, + ai: { + content: content || '', // Include empty files + }, + })) } export default RemoteDirectoryProvider From 996e08e77532441c28e8b0312f51bb985c41101b Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 31 Jul 2025 18:58:36 +0300 Subject: [PATCH 27/27] fix tooltips for dirname --- vscode/webviews/components/FileLink.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vscode/webviews/components/FileLink.tsx b/vscode/webviews/components/FileLink.tsx index fb6d9f9d2e92..d462e9694750 100644 --- a/vscode/webviews/components/FileLink.tsx +++ b/vscode/webviews/components/FileLink.tsx @@ -100,7 +100,7 @@ export const FileLink: React.FunctionComponent< ? IGNORE_WARNING : isTooLarge ? `${LIMIT_WARNING}${isTooLargeReason ? `: ${isTooLargeReason}` : ''}` - : pathWithRange + : decodeURIComponent(pathWithRange) return { path: pathToDisplay, range: range ? `${displayLineRange(range)}` : undefined,