From df19b211d50da4acb5dda1b59e27909a8f68be49 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 14 Oct 2026 10:38:03 -0700 Subject: [PATCH 1/3] Small fixes for cloud provider --- .../contrib/chat/browser/chat.shared.contribution.ts | 2 +- .../browser/remoteAgentHost/editorCloudSandboxContribution.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 944783641e9bb8..d575efc39cd050 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -294,7 +294,7 @@ CommandsRegistry.registerCommand(PICK_REPOSITORY_COMMAND_ID, async (accessor: Se throw new Error('The repository search command did not return repository names'); } return repositories; - }, options); + }, options ?? undefined); // `undefined` arrives as `null` over the extension host RPC } finally { store.dispose(); } diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts index 1f6801375f0221..f039ceb5e7d12a 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts @@ -192,7 +192,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo return [{ id: CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP, name: localize('cloudSandbox.repositoryGroup', "Repository"), - description: localize('cloudSandbox.repositoryGroupDescription', "The GitHub repository to clone into the sandbox"), + description: localize('cloudSandbox.repositoryGroupDescription', "Select repository..."), icon: Codicon.repo, items, }]; @@ -202,7 +202,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo return [{ id: CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP, name: localize('cloudSandbox.repositoryGroup', "Repository"), - description: localize('cloudSandbox.repositoryGroupDescription', "The GitHub repository to clone into the sandbox"), + description: localize('cloudSandbox.repositoryGroupDescription', "Select repository..."), icon: Codicon.repo, selected: { id: SELECT_REPOSITORY_PLACEHOLDER_ID, From 2dab2939af84c27a04f1086ac363667c3f366235 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 15 Oct 2026 00:46:58 -0600 Subject: [PATCH 2/3] chat: pick cloud sandbox repository in the renderer without the Copilot extension The cloud sandbox "Select repository..." button round-tripped through the Copilot Chat extension (openRepository -> _chat.pickRepository -> searchRepositories), which broke on web and whenever the extension was not active, and also surfaced a null-vs-undefined RPC bug. Add CloudSandboxRepositoryPicker, which drives the shared RepositoryPicker UI with IWorkbenchGitHubService: listRepositories for an empty query and searchRepositories scoped to the viewer and their organizations otherwise. The contribution no longer references any extension command. --- .../cloudSandboxRepositoryPicker.ts | 98 +++++++++++++ .../editorCloudSandboxContribution.ts | 21 ++- .../cloudSandboxRepositoryPicker.test.ts | 138 ++++++++++++++++++ .../editorCloudSandboxContribution.test.ts | 23 +-- 4 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts new file mode 100644 index 00000000000000..d421bf8494d48a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceCancellationError } from '../../../../../base/common/async.js'; +import { CancellationToken, cancelOnDispose } from '../../../../../base/common/cancellation.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IGitHubClient } from '../../../../../platform/github/common/githubService.js'; +import { GitHubRequestError } from '../../../../../platform/github/common/githubTypes.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchGitHubService } from '../../../../services/github/common/githubService.js'; +import { getGitHubRepositoryFromRemoteUrl } from '../../../git/common/utils.js'; +import { RepositoryPicker } from '../agentSessions/repositoryPicker.js'; + +const REQUEST_CALLER = 'cloudSandbox.repositoryPicker'; +const PAGE_SIZE = 100; + +interface IGitHubLoginResponse { + readonly login?: string; +} + +/** Searches the signed-in account's GitHub repositories in the renderer, so no extension host is required. */ +export class CloudSandboxRepositoryPicker extends Disposable { + + private readonly _currentPick = this._register(new MutableDisposable()); + + constructor( + @IWorkbenchGitHubService private readonly _gitHubService: IWorkbenchGitHubService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + /** Resolves to the picked `owner/repo`, or `undefined` when dismissed. Rejects when no repository-scoped GitHub session exists. */ + async pick(token: CancellationToken = CancellationToken.None): Promise { + const store = new DisposableStore(); + this._currentPick.value = store; + const pickToken = cancelOnDispose(store); + store.add(token.onCancellationRequested(() => store.dispose())); + const controller = new AbortController(); + store.add(toDisposable(() => controller.abort())); + try { + const client = store.add(await raceCancellationError(this._gitHubService.acquireDefaultAccountClient(controller.signal), pickToken)).object; + // The viewer and their organizations scope every search, so resolve them once per pick. + let scope: Promise | undefined; + const resolveScope = (signal: AbortSignal) => scope ??= this._resolveSearchScope(client, signal).catch(error => { scope = undefined; throw error; }); + const picker = store.add(this._instantiationService.createInstance(RepositoryPicker)); + const selection = await picker.pickRepository(async (query, requestToken) => { + const requestController = new AbortController(); + const listener = requestToken.onCancellationRequested(() => requestController.abort()); + try { + return await this._searchRepositories(client, query, resolveScope, AbortSignal.any([controller.signal, requestController.signal])); + } finally { + listener.dispose(); + } + }, undefined, pickToken); + return selection?.repository; + } finally { + store.dispose(); + if (this._currentPick.value === store) { + this._currentPick.clear(); + } + } + } + + private async _searchRepositories(client: IGitHubClient, query: string, resolveScope: (signal: AbortSignal) => Promise, signal: AbortSignal): Promise { + const { account } = await client.credentials.getCredential(signal); + const trimmed = query.trim(); + if (!trimmed) { + const page = await client.query.listRepositories(account, signal, { + affiliation: ['owner', 'collaborator', 'organization_member'], + sort: 'updated', + perPage: PAGE_SIZE, + }); + return page.repositories.map(repository => repository.nameWithOwner); + } + // A pasted GitHub URL searches for its `owner/repo` instead of the raw URL. + const remote = getGitHubRepositoryFromRemoteUrl(trimmed); + const term = remote ? `${remote.owner}/${remote.repo}` : trimmed; + const page = await client.query.searchRepositories(account, `${term} in:name fork:true ${await resolveScope(signal)}`, signal, { sort: 'updated', perPage: PAGE_SIZE }); + return page.repositories.map(repository => repository.nameWithOwner); + } + + /** `user: org:...` qualifiers restricting search to repositories the account can reach. */ + private async _resolveSearchScope(client: IGitHubClient, signal: AbortSignal): Promise { + const { account, token } = await client.credentials.getCredential(signal); + const apiBaseUri = client.endpoint.getApiBaseUri(); + const [user, organizations] = await Promise.all([ + client.transport.rest(account, token, { caller: REQUEST_CALLER, method: 'GET', url: `${apiBaseUri}/user`, etag: true, priority: 'interactive' }, signal), + client.transport.rest(account, token, { caller: REQUEST_CALLER, method: 'GET', url: `${apiBaseUri}/user/orgs?per_page=${PAGE_SIZE}`, etag: true, priority: 'interactive' }, signal), + ]); + if (!user.data?.login || !Array.isArray(organizations.data)) { + throw new GitHubRequestError('GitHub did not return an account and organizations for repository search', 'malformedResponse'); + } + return [`user:${user.data.login}`, ...organizations.data.flatMap(organization => organization.login ? [`org:${organization.login}`] : [])].join(' '); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts index f039ceb5e7d12a..592be988b4473e 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts @@ -22,7 +22,7 @@ import { ChangesetKind } from '../../../../../platform/agentHost/common/changese import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME, cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -44,6 +44,7 @@ import { ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionIt import { isUntitledChatSession } from '../../common/model/chatUri.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { CloudSandboxModels } from './cloudSandboxModels.js'; +import { CloudSandboxRepositoryPicker } from './cloudSandboxRepositoryPicker.js'; import { createCloudSandboxDraftOptionGroups, createCloudSandboxSessionConfig, readCloudSandboxDraftConfig, resolveCloudSandboxSessionConfig } from './cloudSandboxSessionConfig.js'; import { CloudSandboxSessionContribution, ICloudSandboxSessionEnvironment } from './cloudSandboxSessionContribution.js'; import { CloudSandboxSessionListController } from './cloudSandboxSessionListController.js'; @@ -55,8 +56,6 @@ export const CLOUD_SANDBOX_SESSION_TYPE = SessionType.CloudSandbox; export const CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP = 'repository'; /** Opens the GitHub repository picker and records the pick as the draft's repository option. */ export const CLOUD_SANDBOX_SELECT_REPOSITORY_COMMAND_ID = 'workbench.action.chat.cloudSandbox.selectRepository'; -/** The Copilot Chat extension's repository search, shared with the Agents window on desktop. */ -const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository'; /** Pre-selected while no repository is known; a slash-free id so it can never be mistaken for a pick. */ const SELECT_REPOSITORY_PLACEHOLDER_ID = 'select-repository'; const REPOSITORY_NWO_PATTERN = /^[\w.-]+\/[\w.-]+$/; @@ -90,6 +89,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo private readonly _draftOptionGroups: IObservable; /** Models offered on a draft, from the shared cloud catalog; alive while the creation type is registered. */ private readonly _draftModels = this._register(new MutableDisposable()); + private readonly _repositoryPicker = this._register(new MutableDisposable()); constructor( @ICloudSandboxAgentHostService cloudSandboxService: ICloudSandboxAgentHostService, @@ -286,7 +286,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo // Also keeps refresh available before discovery has found the first environment. store.add(this._editorChatSessionsService.registerChatSessionItemController(CLOUD_SANDBOX_SESSION_TYPE, this)); store.add(CommandsRegistry.registerCommand(CLOUD_SANDBOX_SELECT_REPOSITORY_COMMAND_ID, (accessor, sessionResource?: URI) => - this._selectRepository(accessor.get(ICommandService), accessor.get(INotificationService), sessionResource))); + this._selectRepository(accessor.get(INotificationService), sessionResource))); this._editorChatSessionsService.setOptionGroupsForSessionType(CLOUD_SANDBOX_SESSION_TYPE, 0, this._draftOptionGroups.get()); store.add(toDisposable(() => this._editorChatSessionsService.setOptionGroupsForSessionType(CLOUD_SANDBOX_SESSION_TYPE, 0, undefined))); } else { @@ -294,11 +294,10 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo } } - /** Runs the extension's GitHub repository search and records the pick on the draft that invoked it. */ - private async _selectRepository(commandService: ICommandService, notificationService: INotificationService, sessionResource: URI | undefined): Promise { + /** Records the picked repository on the draft that invoked the picker. */ + private async _selectRepository(notificationService: INotificationService, sessionResource: URI | undefined): Promise { try { - // Without a resource the extension only returns the pick instead of also writing its own option. - const repoNwo = await commandService.executeCommand(OPEN_REPOSITORY_COMMAND_ID); + const repoNwo = await this._pickRepository(); if (repoNwo && URI.isUri(sessionResource)) { this._editorChatSessionsService.setSessionOption(sessionResource, CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP, { id: repoNwo, name: repoNwo, icon: Codicon.repo }); } @@ -309,6 +308,12 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo } } + /** Searches the signed-in account's GitHub repositories in the renderer; no extension host is involved. */ + protected _pickRepository(): Promise { + const picker = this._repositoryPicker.value ??= this._instantiationService.createInstance(CloudSandboxRepositoryPicker); + return picker.pick(); + } + protected override _createProvider(env: ICloudSandboxSessionEnvironment, store: DisposableStore): CloudSandboxSessionListController { const address = cloudSandboxAddress(env.environmentId); const provider = store.add(this._instantiationService.createInstance(CloudSandboxSessionListController, address, this._workspaceRepositories, diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts new file mode 100644 index 00000000000000..b1d95f901de07c --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import sinon from 'sinon'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { DisposableStore, IReference } from '../../../../../../base/common/lifecycle.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { GitHubCredential, IGitHubCredentials } from '../../../../../../platform/github/common/githubCredentialService.js'; +import { GitHubRepositoriesPage, GitHubRepository, GitHubRepositoryListOptions, GitHubRepositorySearchOptions, GitHubRepositorySearchPage } from '../../../../../../platform/github/common/githubQueryService.js'; +import { IGitHubQuery } from '../../../../../../platform/github/common/githubQueryServiceImpl.js'; +import { IGitHubClient } from '../../../../../../platform/github/common/githubService.js'; +import { GitHubRestRequest, GitHubRestResponse, IGitHubTransport } from '../../../../../../platform/github/common/githubTransport.js'; +import { GitHubRequestError, IGitHubEndpointProvider } from '../../../../../../platform/github/common/githubTypes.js'; +import { AccountHandle } from '../../../../../../platform/github/common/types.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IQuickInputHideEvent, IQuickInputService, IQuickPick, IQuickPickDidAcceptEvent, IQuickPickItem, QuickInputHideReason } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IWorkbenchGitHubService } from '../../../../../services/github/common/githubService.js'; +import { CloudSandboxRepositoryPicker } from '../../../browser/remoteAgentHost/cloudSandboxRepositoryPicker.js'; + +class TestQuickPick extends mock>() { + private readonly store = new DisposableStore(); + private readonly hideEmitter = this.store.add(new Emitter()); + private readonly valueEmitter = this.store.add(new Emitter()); + private readonly acceptEmitter = this.store.add(new Emitter()); + override readonly onDidHide = this.hideEmitter.event; + override readonly onDidChangeValue = this.valueEmitter.event; + override readonly onDidAccept = this.acceptEmitter.event; + override items: readonly IQuickPickItem[] = []; + override selectedItems: readonly IQuickPickItem[] = []; + override value = ''; + override busy = false; + private shown = false; + + override show(): void { this.shown = true; } + override hide(): void { + if (this.shown) { + this.shown = false; + this.hideEmitter.fire({ reason: QuickInputHideReason.Other }); + } + } + changeValue(value: string): void { + this.value = value; + this.valueEmitter.fire(value); + } + acceptItem(index = 0): void { + const item = this.items[index]; + assert.ok(item); + this.selectedItems = [item]; + this.acceptEmitter.fire({ inBackground: false }); + } + override dispose(): void { + this.hide(); + this.store.dispose(); + } +} + +suite('CloudSandboxRepositoryPicker', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const account: AccountHandle = { host: 'api.github.com', accountId: '1' }; + let clock: sinon.SinonFakeTimers; + + setup(() => { clock = sinon.useFakeTimers(); }); + teardown(() => sinon.restore()); + + function createPicker(options?: { readonly acquireClient?: () => Promise> }) { + const quickPicks: TestQuickPick[] = []; + const requests: { readonly kind: string; readonly value: string; readonly options?: object }[] = []; + const client = upcastPartial({ + credentials: upcastPartial({ + getCredential: async () => upcastPartial({ account, token: 'token' }), + }), + endpoint: upcastPartial({ getApiBaseUri: () => 'https://api.github.com' }), + transport: upcastPartial({ + rest: async (_account: AccountHandle, _token: string, request: GitHubRestRequest): Promise> => { + requests.push({ kind: 'rest', value: request.url }); + const data = request.url.endsWith('/user') ? { login: 'octocat' } : [{ login: 'github' }, { login: 'microsoft' }]; + return upcastPartial>({ data: data as T }); + }, + }), + query: upcastPartial({ + listRepositories: async (_account: AccountHandle, _signal: AbortSignal, listOptions?: GitHubRepositoryListOptions): Promise => { + requests.push({ kind: 'list', value: '', options: listOptions }); + return { repositories: [upcastPartial({ nameWithOwner: 'octocat/recent' })] }; + }, + searchRepositories: async (_account: AccountHandle, query: string, _signal: AbortSignal, searchOptions?: GitHubRepositorySearchOptions): Promise => { + requests.push({ kind: 'search', value: query, options: searchOptions }); + return { repositories: [upcastPartial({ nameWithOwner: 'microsoft/vscode' })], totalCount: 1, incompleteResults: false, limitReached: false }; + }, + }), + }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IQuickInputService, {}, 'createQuickPick', () => { + const quickPick = new TestQuickPick(); + quickPicks.push(quickPick); + return quickPick; + }); + instantiationService.stub(IWorkbenchGitHubService, upcastPartial({ + acquireDefaultAccountClient: options?.acquireClient ?? (async () => ({ object: client, dispose: () => { } })), + })); + const picker = store.add(instantiationService.createInstance(CloudSandboxRepositoryPicker)); + return { picker, quickPicks, requests }; + } + + test('lists recent repositories for an empty query and scopes searches to the viewer and their organizations', async () => { + const { picker, quickPicks, requests } = createPicker(); + const result = picker.pick(); + await clock.tickAsync(0); + const quickPick = quickPicks[0]; + const initialItems = quickPick.items.map(item => item.label); + quickPick.changeValue('https://github.com/microsoft/vscode.git'); + await clock.tickAsync(300); + quickPick.acceptItem(); + assert.deepStrictEqual({ initialItems, requests, result: await result }, { + initialItems: ['octocat/recent'], + requests: [ + { kind: 'list', value: '', options: { affiliation: ['owner', 'collaborator', 'organization_member'], sort: 'updated', perPage: 100 } }, + { kind: 'rest', value: 'https://api.github.com/user' }, + { kind: 'rest', value: 'https://api.github.com/user/orgs?per_page=100' }, + { kind: 'search', value: 'microsoft/vscode in:name fork:true user:octocat org:github org:microsoft', options: { sort: 'updated', perPage: 100 } }, + ], + result: 'microsoft/vscode', + }); + }); + + test('rejects before showing a picker when no repository-scoped GitHub session exists', async () => { + const { picker, quickPicks } = createPicker({ + acquireClient: async () => { throw new GitHubRequestError('Sign in to GitHub with repository access to load GitHub data.', 'authentication'); }, + }); + await assert.rejects(picker.pick(), { kind: 'authentication' }); + assert.strictEqual(quickPicks.length, 0); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts index cdea80dfedc75e..88cc22084ebd91 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts @@ -86,8 +86,6 @@ const sessionType = remoteAgentHostSessionTypeId(authority, CLOUD_SANDBOX_AGENT_ const resource = URI.from({ scheme: sessionType, path: `/${discovered.sessionId}` }); const backendSession = AgentSession.uri('ahp-session', discovered.sessionId); const workspaceFolder = URI.file('/local/project'); -/** The Copilot Chat extension's repository search that the editor delegates to. */ -const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository'; const otherDiscovered: ICloudSandboxDiscoveredSession = { ...discovered, environmentId: 'environment-two', @@ -116,9 +114,13 @@ function scmRepository(repository: IGitRepository): ISCMRepository { } class TestEditorCloudSandboxContribution extends EditorCloudSandboxSessionContribution { + pickRepository: () => Promise = () => { throw new Error('repository picker not available'); }; activate(): Promise { return this._waitForActivation(sessionType); } + protected override _pickRepository(): Promise { + return this.pickRepository(); + } } function history(): IReplayedTaskHistory { @@ -165,7 +167,7 @@ function createHarness(store: Pick, options?: { readonly deleteTask?: (taskId: string, token: CancellationToken) => Promise; readonly setTaskArchived?: (taskId: string, archived: boolean, token: CancellationToken) => Promise; readonly createSession?: (request: ICloudSandboxCreateSessionRequest, token: CancellationToken) => Promise; - readonly commands?: Record Promise>; + readonly pickRepository?: () => Promise; }) { const instantiationService = store.add(new TestInstantiationService()); const configuration = new TestConfigurationService({ @@ -173,12 +175,8 @@ function createHarness(store: Pick, options?: { [RemoteAgentHostsEnabledSettingId]: true, }); instantiationService.stub(ICommandService, new class extends mock() { - override async executeCommand(id: string, ...args: unknown[]): Promise { - const command = options?.commands?.[id]; - if (!command) { - throw new Error(`command '${id}' not found`); - } - return await command(...args) as T; + override async executeCommand(id: string): Promise { + throw new Error(`command '${id}' not found`); } }()); const connectionsChanged = store.add(new Emitter()); @@ -469,6 +467,9 @@ function createHarness(store: Pick, options?: { instantiationService.stub(IAgentHostImportConversationStore, new class extends mock() { }()); instantiationService.stub(IAgentHostNewSessionFolderService, new class extends mock() { }()); const contribution = store.add(instantiationService.createInstance(TestEditorCloudSandboxContribution)); + if (options?.pickRepository) { + contribution.pickRepository = options.pickRepository; + } return { instantiationService, contribution, controllers, contributions, contentProviders, optionGroups, sessionOptions, catalogLoads, languageModelVendors, customizations, progressMessages, chatSessionsService, state, calls, policies, notifications, errorNotifications, resolvers, sentimentChanged, accountChanged, authenticationPending, initialRefreshes, connectionsChanged, focusChanged, discoveryModes, workspaceTrust, whenConnected: initialConnection.p, @@ -901,7 +902,7 @@ suite('Editor cloud sandbox discovery', () => { workspaceFolders: [], repositories: [], createSession: async () => created, connect: async () => { h.contentProviders.set(sessionType, upcastPartial({})); }, - commands: { [OPEN_REPOSITORY_COMMAND_ID]: async () => 'Searched/Repo' }, + pickRepository: async () => 'Searched/Repo', }); h.state.result = { kind: 'complete', sessions: [] }; await h.refresh(); @@ -923,7 +924,7 @@ suite('Editor cloud sandbox discovery', () => { test('a cancelled or failed repository pick leaves the draft untouched', async () => { const h = createHarness(store, { workspaceFolders: [], repositories: [], - commands: { [OPEN_REPOSITORY_COMMAND_ID]: async () => { throw new Error('search unavailable'); } }, + pickRepository: async () => { throw new Error('search unavailable'); }, }); await h.refresh(); const draft = URI.from({ scheme: CLOUD_SANDBOX_SESSION_TYPE, path: '/untitled-draft' }); From 8995a4eb0db5a050788adadfb617aa5ab2da6040 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 15 Oct 2026 01:06:13 -0600 Subject: [PATCH 3/3] chat: filter repository picker items by the normalized search term A pasted GitHub URL was rewritten to owner/repo for the API query only, so the quick pick's local label filter still used the raw URL and hid the matching result. Let RepositoryPicker callers supply a filterValue and apply the same normalization there. --- .../browser/agentSessions/repositoryPicker.ts | 5 +++++ .../cloudSandboxRepositoryPicker.ts | 16 ++++++++++------ .../cloudSandboxRepositoryPicker.test.ts | 16 +++++++++++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts index aef5f9aab1c95a..ca9d79632c6587 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts @@ -17,6 +17,8 @@ export const PICK_REPOSITORY_COMMAND_ID = '_chat.pickRepository'; export interface IRepositoryPickerOptions { readonly allowRepositoryUrl?: boolean; readonly placeholder?: string; + /** Maps the typed value to what items are filtered by locally, so a search term that is rewritten for the API still matches its results. */ + readonly filterValue?: (value: string) => string; } export interface IRepositoryPickResult { @@ -58,6 +60,9 @@ export class RepositoryPicker extends Disposable { quickPick.ariaLabel = quickPick.placeholder; quickPick.matchOnDescription = true; quickPick.matchOnDetail = true; + if (options.filterValue) { + quickPick.filterValue = options.filterValue; + } const updateItems = async () => { requests.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts index d421bf8494d48a..928e9ecb811a8b 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts @@ -20,6 +20,13 @@ interface IGitHubLoginResponse { readonly login?: string; } +/** A pasted GitHub URL stands for its `owner/repo`, both for the search and for filtering its results. */ +function toRepositorySearchTerm(value: string): string { + const trimmed = value.trim(); + const remote = getGitHubRepositoryFromRemoteUrl(trimmed); + return remote ? `${remote.owner}/${remote.repo}` : trimmed; +} + /** Searches the signed-in account's GitHub repositories in the renderer, so no extension host is required. */ export class CloudSandboxRepositoryPicker extends Disposable { @@ -54,7 +61,7 @@ export class CloudSandboxRepositoryPicker extends Disposable { } finally { listener.dispose(); } - }, undefined, pickToken); + }, { filterValue: toRepositorySearchTerm }, pickToken); return selection?.repository; } finally { store.dispose(); @@ -66,8 +73,8 @@ export class CloudSandboxRepositoryPicker extends Disposable { private async _searchRepositories(client: IGitHubClient, query: string, resolveScope: (signal: AbortSignal) => Promise, signal: AbortSignal): Promise { const { account } = await client.credentials.getCredential(signal); - const trimmed = query.trim(); - if (!trimmed) { + const term = toRepositorySearchTerm(query); + if (!term) { const page = await client.query.listRepositories(account, signal, { affiliation: ['owner', 'collaborator', 'organization_member'], sort: 'updated', @@ -75,9 +82,6 @@ export class CloudSandboxRepositoryPicker extends Disposable { }); return page.repositories.map(repository => repository.nameWithOwner); } - // A pasted GitHub URL searches for its `owner/repo` instead of the raw URL. - const remote = getGitHubRepositoryFromRemoteUrl(trimmed); - const term = remote ? `${remote.owner}/${remote.repo}` : trimmed; const page = await client.query.searchRepositories(account, `${term} in:name fork:true ${await resolveScope(signal)}`, signal, { sort: 'updated', perPage: PAGE_SIZE }); return page.repositories.map(repository => repository.nameWithOwner); } diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts index b1d95f901de07c..ef8edd8434c53b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import sinon from 'sinon'; import { Emitter } from '../../../../../../base/common/event.js'; +import { matchesFuzzy } from '../../../../../../base/common/filters.js'; import { DisposableStore, IReference } from '../../../../../../base/common/lifecycle.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -34,8 +35,15 @@ class TestQuickPick extends mock>() { override selectedItems: readonly IQuickPickItem[] = []; override value = ''; override busy = false; + override filterValue = (value: string) => value; private shown = false; + /** Items surviving the label filter the real quick pick applies to the typed value. */ + get visibleItems(): readonly IQuickPickItem[] { + const filter = this.filterValue(this.value).trim(); + return filter ? this.items.filter(item => matchesFuzzy(filter, item.label, true)) : this.items; + } + override show(): void { this.shown = true; } override hide(): void { if (this.shown) { @@ -48,8 +56,8 @@ class TestQuickPick extends mock>() { this.valueEmitter.fire(value); } acceptItem(index = 0): void { - const item = this.items[index]; - assert.ok(item); + const item = this.visibleItems[index]; + assert.ok(item, 'the item must survive local filtering to be accepted'); this.selectedItems = [item]; this.acceptEmitter.fire({ inBackground: false }); } @@ -115,9 +123,11 @@ suite('CloudSandboxRepositoryPicker', () => { const initialItems = quickPick.items.map(item => item.label); quickPick.changeValue('https://github.com/microsoft/vscode.git'); await clock.tickAsync(300); + const visibleAfterUrl = quickPick.visibleItems.map(item => item.label); quickPick.acceptItem(); - assert.deepStrictEqual({ initialItems, requests, result: await result }, { + assert.deepStrictEqual({ initialItems, visibleAfterUrl, requests, result: await result }, { initialItems: ['octocat/recent'], + visibleAfterUrl: ['microsoft/vscode'], requests: [ { kind: 'list', value: '', options: { affiliation: ['owner', 'collaborator', 'organization_member'], sort: 'updated', perPage: 100 } }, { kind: 'rest', value: 'https://api.github.com/user' },