Loading src/common/feature_flags/constants.ts +2 −0 Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ export enum FeatureFlag { FixWithDuoQuickChatCodeActions = 'fixWithDuoQuickChatCodeActions', LsCredentialsSync = 'lsCredentialsSync', FormatEdits = 'formatEdits', LsRepositories = 'useLanguageServerRepositories', } // Set the feature flag default value here Loading @@ -37,6 +38,7 @@ export const FEATURE_FLAGS_DEFAULT_VALUES = { [FeatureFlag.FixWithDuoQuickChatCodeActions]: true, [FeatureFlag.LsCredentialsSync]: true, [FeatureFlag.FormatEdits]: false, [FeatureFlag.LsRepositories]: false, }; // PLEASE NOTE: We can only query 20 flags at a time so this list shouldn't grow past that. Loading src/common/language_server/language_client_wrapper.test.ts +13 −0 Original line number Diff line number Diff line Loading @@ -38,6 +38,7 @@ import { LanguageClientWrapper, LanguageClientWrapperImpl } from './language_cli import { LanguageServerFeatureStateProvider } from './language_server_feature_state_provider'; import { FileSnapshotProvider } from './file_snapshot_provider'; import { GET_DIAGNOSTICS_REQUEST_METHOD } from './document_quality_handler'; import { RepositoryClient } from './repository_client'; jest.mock('../code_suggestions/gitlab_platform_manager_for_code_suggestions'); jest.mock('../log'); // disable logging in tests Loading Loading @@ -66,6 +67,8 @@ describe('LanguageClientWrapper', () => { let fileSnapshotProvider: FileSnapshotProvider; let repositoryClient: RepositoryClient; let wrapper: LanguageClientWrapper; const createWrapper = ( Loading @@ -78,6 +81,7 @@ describe('LanguageClientWrapper', () => { mockFeatureStateManager: LanguageServerFeatureStateProvider; mockTerminalManager: TerminalManager; mockFileSnapshotProvider: FileSnapshotProvider; mockRepositoryClient: RepositoryClient; }> = {}, ) => { const { Loading @@ -89,6 +93,7 @@ describe('LanguageClientWrapper', () => { mockFeatureStateManager = languageServerFeatureStateProvider, mockTerminalManager = terminalManager, mockFileSnapshotProvider = fileSnapshotProvider, mockRepositoryClient = repositoryClient, } = options; return new LanguageClientWrapperImpl( Loading @@ -100,6 +105,7 @@ describe('LanguageClientWrapper', () => { mockFeatureStateManager, mockTerminalManager, mockFileSnapshotProvider, mockRepositoryClient, ); }; Loading Loading @@ -138,6 +144,13 @@ describe('LanguageClientWrapper', () => { }); fileSnapshotProvider = createFakePartial<FileSnapshotProvider>({}); repositoryClient = createFakePartial<RepositoryClient>({ setRequestFunction: jest.fn(), handleRepositoriesChanged: jest.fn(), getRepositories: jest.fn(), onRepositoriesChanged: jest.fn(), }); }); afterEach(() => { Loading src/common/language_server/language_client_wrapper.ts +20 −0 Original line number Diff line number Diff line Loading @@ -14,6 +14,9 @@ import { GitDiffRequest, FeatureStateChangeNotificationType, ClientConfig, RepositoriesChangedNotificationType, GetRepositoriesResponse, RepositoryEndpoints, } from '@gitlab-org/gitlab-lsp'; import { BaseLanguageClient, Loading Loading @@ -57,6 +60,7 @@ import { SaveFileMiddleware } from './save_file_middleware'; import { DiffMiddleware } from './diff_middleware'; import { FileSnapshotProvider } from './file_snapshot_provider'; import { FormatEditsMiddleware } from './format_edits_middleware'; import { RepositoryClient } from './repository_client'; // Interface IDs for dependency injection export const BaseLanguageClientId = createInterfaceId<BaseLanguageClient>('BaseLanguageClient'); Loading Loading @@ -93,6 +97,7 @@ export const LanguageClientWrapper = LanguageServerFeatureStateProvider, TerminalManager, FileSnapshotProvider, RepositoryClient, ]) export class LanguageClientWrapperImpl implements LanguageClientWrapper { #client: BaseLanguageClient; Loading @@ -117,6 +122,8 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { #terminalManager: TerminalManager; #repositoryClient: RepositoryClient; constructor( client: BaseLanguageClient, suggestionsManager: GitLabPlatformManagerForCodeSuggestions, Loading @@ -126,6 +133,7 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { languageServerFeatureStateProvider: LanguageServerFeatureStateProvider, terminalManager: TerminalManager, fileSnapshotProvider: FileSnapshotProvider, repositoryClient: RepositoryClient, ) { this.#client = client; this.#suggestionsManager = suggestionsManager; Loading @@ -136,6 +144,7 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { this.#terminalManager = terminalManager; this.#fileSnapshotProvider = fileSnapshotProvider; this.#documentQualityHandler = new DocumentQualityHandler(); this.#repositoryClient = repositoryClient; this.#subscriptions.push(this.#documentQualityHandler); } Loading @@ -162,6 +171,17 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { }), ); this.#repositoryClient.setRequestFunction(() => this.#client.sendRequest<GetRepositoriesResponse>(RepositoryEndpoints.GET_REPOSITORIES), ); this.#client.onNotification( RepositoriesChangedNotificationType, (change: GetRepositoriesResponse) => { this.#repositoryClient.handleRepositoriesChanged(change); }, ); this.#terminalManager.setupRequests(this.#client); this.#subscriptions.push(this.#terminalManager); Loading src/common/language_server/repository_client.test.ts 0 → 100644 +149 −0 Original line number Diff line number Diff line import { GetRepositoriesResponse, RepositoryState, SingleProjectRepository, } from '@gitlab-org/gitlab-lsp'; import { createFakePartial } from '../test_utils/create_fake_partial'; import { RepositoryClientImpl } from './repository_client'; describe('RepositoryClient', () => { let mockSendRequest: jest.Mock; let repositoryClient: RepositoryClientImpl; const mockRepository = createFakePartial<SingleProjectRepository>({ type: 'single', repository: { rootFsPath: '/path/to/repo', folderName: 'test-repo', }, }); const mockRepositories: RepositoryState[] = [mockRepository]; beforeEach(() => { mockSendRequest = jest.fn(); repositoryClient = new RepositoryClientImpl(); repositoryClient.setRequestFunction(mockSendRequest); }); describe('setRequestFunction', () => { it('should set the request function', () => { const newMockSendRequest = jest.fn(); repositoryClient.setRequestFunction(newMockSendRequest); expect(() => repositoryClient.setRequestFunction(newMockSendRequest)).not.toThrow(); }); }); describe('getRepositories', () => { it('should return cached repositories if available', async () => { repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); const result = await repositoryClient.getRepositories(); expect(result).toEqual(mockRepositories); expect(mockSendRequest).not.toHaveBeenCalled(); }); it('should fetch repositories from language server if cache is empty', async () => { const mockResponse: GetRepositoriesResponse = { repositories: mockRepositories, }; mockSendRequest.mockResolvedValue(mockResponse); const result = await repositoryClient.getRepositories(); expect(mockSendRequest).toHaveBeenCalledWith(); expect(result).toEqual(mockRepositories); }); it('should return empty array if language server request fails', async () => { mockSendRequest.mockRejectedValue(new Error('Request failed')); const result = await repositoryClient.getRepositories(); expect(result).toEqual([]); }); it('should cache repositories after successful request', async () => { const mockResponse: GetRepositoriesResponse = { repositories: mockRepositories, }; mockSendRequest.mockResolvedValue(mockResponse); // First call should make request await repositoryClient.getRepositories(); expect(mockSendRequest).toHaveBeenCalledTimes(1); // Second call should use cache const result = await repositoryClient.getRepositories(); expect(mockSendRequest).toHaveBeenCalledTimes(1); expect(result).toEqual(mockRepositories); }); it('should return empty array if request function is not set', async () => { const newRepositoryClient = new RepositoryClientImpl(); const result = await newRepositoryClient.getRepositories(); expect(result).toEqual([]); }); }); describe('handleRepositoriesChanged', () => { it('should emit event when repositories change', () => { const listener = jest.fn(); repositoryClient.onRepositoriesChanged(listener); repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); expect(listener).toHaveBeenCalledWith(mockRepositories); }); it('should update cached repositories when notification is received', async () => { const newRepository: SingleProjectRepository = { ...mockRepository, repository: { ...mockRepository.repository, rootFsPath: '/path/to/new-repo', folderName: 'new-repo', }, }; const newRepositories: RepositoryState[] = [newRepository]; repositoryClient.handleRepositoriesChanged({ repositories: newRepositories }); const result = await repositoryClient.getRepositories(); expect(result).toEqual(newRepositories); expect(mockSendRequest).not.toHaveBeenCalled(); }); it('should not emit duplicate events for same repositories', () => { const listener = jest.fn(); repositoryClient.onRepositoriesChanged(listener); // First notification repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); expect(listener).toHaveBeenCalledTimes(1); // Same repositories - should not emit again due to diffEmitter repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); expect(listener).toHaveBeenCalledTimes(1); // Different repositories - should emit const differentRepository: SingleProjectRepository = { ...mockRepository, repository: { ...mockRepository.repository, rootFsPath: '/different/path', }, }; const differentRepositories = [differentRepository]; repositoryClient.handleRepositoriesChanged({ repositories: differentRepositories }); expect(listener).toHaveBeenCalledTimes(2); }); }); }); src/common/language_server/repository_client.ts 0 → 100644 +55 −0 Original line number Diff line number Diff line import * as vscode from 'vscode'; import { GetRepositoriesResponse, RepositoryState } from '@gitlab-org/gitlab-lsp'; import { createInterfaceId } from '@gitlab/needle'; import { log } from '../log'; import { diffEmitter } from '../utils/diff_emitter'; export interface RepositoryClient { getRepositories(): Promise<RepositoryState[]>; onRepositoriesChanged: vscode.Event<RepositoryState[]>; handleRepositoriesChanged(change: GetRepositoriesResponse): void; setRequestFunction(requestFn: (params?: unknown) => Promise<GetRepositoriesResponse>): void; } export const RepositoryClient = createInterfaceId<RepositoryClient>('RepositoryClient'); export class RepositoryClientImpl implements RepositoryClient { #emitter = diffEmitter(new vscode.EventEmitter<RepositoryState[]>()); #cachedRepositories: RepositoryState[] = []; #sendRequest?: (params?: unknown) => Promise<GetRepositoriesResponse>; readonly onRepositoriesChanged = this.#emitter.event; setRequestFunction(requestFn: (params?: unknown) => Promise<GetRepositoriesResponse>) { this.#sendRequest = requestFn; } handleRepositoriesChanged(change: GetRepositoriesResponse) { const repositories = change.repositories ?? []; this.#cachedRepositories = repositories; this.#emitter.fire(repositories); } async getRepositories(): Promise<RepositoryState[]> { if (this.#cachedRepositories.length > 0) { return this.#cachedRepositories; } if (!this.#sendRequest) { log.warn('Repository client not initialized with request function'); return []; } try { const result = await this.#sendRequest(); const repositories = result.repositories ?? []; this.#cachedRepositories = repositories; return repositories; } catch (error) { log.error('Failed to get repositories:', error); return []; } } } Loading
src/common/feature_flags/constants.ts +2 −0 Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ export enum FeatureFlag { FixWithDuoQuickChatCodeActions = 'fixWithDuoQuickChatCodeActions', LsCredentialsSync = 'lsCredentialsSync', FormatEdits = 'formatEdits', LsRepositories = 'useLanguageServerRepositories', } // Set the feature flag default value here Loading @@ -37,6 +38,7 @@ export const FEATURE_FLAGS_DEFAULT_VALUES = { [FeatureFlag.FixWithDuoQuickChatCodeActions]: true, [FeatureFlag.LsCredentialsSync]: true, [FeatureFlag.FormatEdits]: false, [FeatureFlag.LsRepositories]: false, }; // PLEASE NOTE: We can only query 20 flags at a time so this list shouldn't grow past that. Loading
src/common/language_server/language_client_wrapper.test.ts +13 −0 Original line number Diff line number Diff line Loading @@ -38,6 +38,7 @@ import { LanguageClientWrapper, LanguageClientWrapperImpl } from './language_cli import { LanguageServerFeatureStateProvider } from './language_server_feature_state_provider'; import { FileSnapshotProvider } from './file_snapshot_provider'; import { GET_DIAGNOSTICS_REQUEST_METHOD } from './document_quality_handler'; import { RepositoryClient } from './repository_client'; jest.mock('../code_suggestions/gitlab_platform_manager_for_code_suggestions'); jest.mock('../log'); // disable logging in tests Loading Loading @@ -66,6 +67,8 @@ describe('LanguageClientWrapper', () => { let fileSnapshotProvider: FileSnapshotProvider; let repositoryClient: RepositoryClient; let wrapper: LanguageClientWrapper; const createWrapper = ( Loading @@ -78,6 +81,7 @@ describe('LanguageClientWrapper', () => { mockFeatureStateManager: LanguageServerFeatureStateProvider; mockTerminalManager: TerminalManager; mockFileSnapshotProvider: FileSnapshotProvider; mockRepositoryClient: RepositoryClient; }> = {}, ) => { const { Loading @@ -89,6 +93,7 @@ describe('LanguageClientWrapper', () => { mockFeatureStateManager = languageServerFeatureStateProvider, mockTerminalManager = terminalManager, mockFileSnapshotProvider = fileSnapshotProvider, mockRepositoryClient = repositoryClient, } = options; return new LanguageClientWrapperImpl( Loading @@ -100,6 +105,7 @@ describe('LanguageClientWrapper', () => { mockFeatureStateManager, mockTerminalManager, mockFileSnapshotProvider, mockRepositoryClient, ); }; Loading Loading @@ -138,6 +144,13 @@ describe('LanguageClientWrapper', () => { }); fileSnapshotProvider = createFakePartial<FileSnapshotProvider>({}); repositoryClient = createFakePartial<RepositoryClient>({ setRequestFunction: jest.fn(), handleRepositoriesChanged: jest.fn(), getRepositories: jest.fn(), onRepositoriesChanged: jest.fn(), }); }); afterEach(() => { Loading
src/common/language_server/language_client_wrapper.ts +20 −0 Original line number Diff line number Diff line Loading @@ -14,6 +14,9 @@ import { GitDiffRequest, FeatureStateChangeNotificationType, ClientConfig, RepositoriesChangedNotificationType, GetRepositoriesResponse, RepositoryEndpoints, } from '@gitlab-org/gitlab-lsp'; import { BaseLanguageClient, Loading Loading @@ -57,6 +60,7 @@ import { SaveFileMiddleware } from './save_file_middleware'; import { DiffMiddleware } from './diff_middleware'; import { FileSnapshotProvider } from './file_snapshot_provider'; import { FormatEditsMiddleware } from './format_edits_middleware'; import { RepositoryClient } from './repository_client'; // Interface IDs for dependency injection export const BaseLanguageClientId = createInterfaceId<BaseLanguageClient>('BaseLanguageClient'); Loading Loading @@ -93,6 +97,7 @@ export const LanguageClientWrapper = LanguageServerFeatureStateProvider, TerminalManager, FileSnapshotProvider, RepositoryClient, ]) export class LanguageClientWrapperImpl implements LanguageClientWrapper { #client: BaseLanguageClient; Loading @@ -117,6 +122,8 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { #terminalManager: TerminalManager; #repositoryClient: RepositoryClient; constructor( client: BaseLanguageClient, suggestionsManager: GitLabPlatformManagerForCodeSuggestions, Loading @@ -126,6 +133,7 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { languageServerFeatureStateProvider: LanguageServerFeatureStateProvider, terminalManager: TerminalManager, fileSnapshotProvider: FileSnapshotProvider, repositoryClient: RepositoryClient, ) { this.#client = client; this.#suggestionsManager = suggestionsManager; Loading @@ -136,6 +144,7 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { this.#terminalManager = terminalManager; this.#fileSnapshotProvider = fileSnapshotProvider; this.#documentQualityHandler = new DocumentQualityHandler(); this.#repositoryClient = repositoryClient; this.#subscriptions.push(this.#documentQualityHandler); } Loading @@ -162,6 +171,17 @@ export class LanguageClientWrapperImpl implements LanguageClientWrapper { }), ); this.#repositoryClient.setRequestFunction(() => this.#client.sendRequest<GetRepositoriesResponse>(RepositoryEndpoints.GET_REPOSITORIES), ); this.#client.onNotification( RepositoriesChangedNotificationType, (change: GetRepositoriesResponse) => { this.#repositoryClient.handleRepositoriesChanged(change); }, ); this.#terminalManager.setupRequests(this.#client); this.#subscriptions.push(this.#terminalManager); Loading
src/common/language_server/repository_client.test.ts 0 → 100644 +149 −0 Original line number Diff line number Diff line import { GetRepositoriesResponse, RepositoryState, SingleProjectRepository, } from '@gitlab-org/gitlab-lsp'; import { createFakePartial } from '../test_utils/create_fake_partial'; import { RepositoryClientImpl } from './repository_client'; describe('RepositoryClient', () => { let mockSendRequest: jest.Mock; let repositoryClient: RepositoryClientImpl; const mockRepository = createFakePartial<SingleProjectRepository>({ type: 'single', repository: { rootFsPath: '/path/to/repo', folderName: 'test-repo', }, }); const mockRepositories: RepositoryState[] = [mockRepository]; beforeEach(() => { mockSendRequest = jest.fn(); repositoryClient = new RepositoryClientImpl(); repositoryClient.setRequestFunction(mockSendRequest); }); describe('setRequestFunction', () => { it('should set the request function', () => { const newMockSendRequest = jest.fn(); repositoryClient.setRequestFunction(newMockSendRequest); expect(() => repositoryClient.setRequestFunction(newMockSendRequest)).not.toThrow(); }); }); describe('getRepositories', () => { it('should return cached repositories if available', async () => { repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); const result = await repositoryClient.getRepositories(); expect(result).toEqual(mockRepositories); expect(mockSendRequest).not.toHaveBeenCalled(); }); it('should fetch repositories from language server if cache is empty', async () => { const mockResponse: GetRepositoriesResponse = { repositories: mockRepositories, }; mockSendRequest.mockResolvedValue(mockResponse); const result = await repositoryClient.getRepositories(); expect(mockSendRequest).toHaveBeenCalledWith(); expect(result).toEqual(mockRepositories); }); it('should return empty array if language server request fails', async () => { mockSendRequest.mockRejectedValue(new Error('Request failed')); const result = await repositoryClient.getRepositories(); expect(result).toEqual([]); }); it('should cache repositories after successful request', async () => { const mockResponse: GetRepositoriesResponse = { repositories: mockRepositories, }; mockSendRequest.mockResolvedValue(mockResponse); // First call should make request await repositoryClient.getRepositories(); expect(mockSendRequest).toHaveBeenCalledTimes(1); // Second call should use cache const result = await repositoryClient.getRepositories(); expect(mockSendRequest).toHaveBeenCalledTimes(1); expect(result).toEqual(mockRepositories); }); it('should return empty array if request function is not set', async () => { const newRepositoryClient = new RepositoryClientImpl(); const result = await newRepositoryClient.getRepositories(); expect(result).toEqual([]); }); }); describe('handleRepositoriesChanged', () => { it('should emit event when repositories change', () => { const listener = jest.fn(); repositoryClient.onRepositoriesChanged(listener); repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); expect(listener).toHaveBeenCalledWith(mockRepositories); }); it('should update cached repositories when notification is received', async () => { const newRepository: SingleProjectRepository = { ...mockRepository, repository: { ...mockRepository.repository, rootFsPath: '/path/to/new-repo', folderName: 'new-repo', }, }; const newRepositories: RepositoryState[] = [newRepository]; repositoryClient.handleRepositoriesChanged({ repositories: newRepositories }); const result = await repositoryClient.getRepositories(); expect(result).toEqual(newRepositories); expect(mockSendRequest).not.toHaveBeenCalled(); }); it('should not emit duplicate events for same repositories', () => { const listener = jest.fn(); repositoryClient.onRepositoriesChanged(listener); // First notification repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); expect(listener).toHaveBeenCalledTimes(1); // Same repositories - should not emit again due to diffEmitter repositoryClient.handleRepositoriesChanged({ repositories: mockRepositories }); expect(listener).toHaveBeenCalledTimes(1); // Different repositories - should emit const differentRepository: SingleProjectRepository = { ...mockRepository, repository: { ...mockRepository.repository, rootFsPath: '/different/path', }, }; const differentRepositories = [differentRepository]; repositoryClient.handleRepositoriesChanged({ repositories: differentRepositories }); expect(listener).toHaveBeenCalledTimes(2); }); }); });
src/common/language_server/repository_client.ts 0 → 100644 +55 −0 Original line number Diff line number Diff line import * as vscode from 'vscode'; import { GetRepositoriesResponse, RepositoryState } from '@gitlab-org/gitlab-lsp'; import { createInterfaceId } from '@gitlab/needle'; import { log } from '../log'; import { diffEmitter } from '../utils/diff_emitter'; export interface RepositoryClient { getRepositories(): Promise<RepositoryState[]>; onRepositoriesChanged: vscode.Event<RepositoryState[]>; handleRepositoriesChanged(change: GetRepositoriesResponse): void; setRequestFunction(requestFn: (params?: unknown) => Promise<GetRepositoriesResponse>): void; } export const RepositoryClient = createInterfaceId<RepositoryClient>('RepositoryClient'); export class RepositoryClientImpl implements RepositoryClient { #emitter = diffEmitter(new vscode.EventEmitter<RepositoryState[]>()); #cachedRepositories: RepositoryState[] = []; #sendRequest?: (params?: unknown) => Promise<GetRepositoriesResponse>; readonly onRepositoriesChanged = this.#emitter.event; setRequestFunction(requestFn: (params?: unknown) => Promise<GetRepositoriesResponse>) { this.#sendRequest = requestFn; } handleRepositoriesChanged(change: GetRepositoriesResponse) { const repositories = change.repositories ?? []; this.#cachedRepositories = repositories; this.#emitter.fire(repositories); } async getRepositories(): Promise<RepositoryState[]> { if (this.#cachedRepositories.length > 0) { return this.#cachedRepositories; } if (!this.#sendRequest) { log.warn('Repository client not initialized with request function'); return []; } try { const result = await this.#sendRequest(); const repositories = result.repositories ?? []; this.#cachedRepositories = repositories; return repositories; } catch (error) { log.error('Failed to get repositories:', error); return []; } } }