Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*---------------------------------------------------------------------------------------------
* 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;
}

/** 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 {

private readonly _currentPick = this._register(new MutableDisposable<DisposableStore>());

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<string | undefined> {
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<string> | 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();
}
}, { filterValue: toRepositorySearchTerm }, 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<string>, signal: AbortSignal): Promise<readonly string[]> {
const { account } = await client.credentials.getCredential(signal);
const term = toRepositorySearchTerm(query);
if (!term) {
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);
}
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:<login> org:<org>...` qualifiers restricting search to repositories the account can reach. */
private async _resolveSearchScope(client: IGitHubClient, signal: AbortSignal): Promise<string> {
const { account, token } = await client.credentials.getCredential(signal);
const apiBaseUri = client.endpoint.getApiBaseUri();
const [user, organizations] = await Promise.all([
client.transport.rest<IGitHubLoginResponse>(account, token, { caller: REQUEST_CALLER, method: 'GET', url: `${apiBaseUri}/user`, etag: true, priority: 'interactive' }, signal),
client.transport.rest<readonly IGitHubLoginResponse[]>(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(' ');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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.-]+$/;
Expand Down Expand Up @@ -90,6 +89,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo
private readonly _draftOptionGroups: IObservable<readonly IChatSessionProviderOptionGroup[]>;
/** Models offered on a draft, from the shared cloud catalog; alive while the creation type is registered. */
private readonly _draftModels = this._register(new MutableDisposable<CloudSandboxModels>());
private readonly _repositoryPicker = this._register(new MutableDisposable<CloudSandboxRepositoryPicker>());

constructor(
@ICloudSandboxAgentHostService cloudSandboxService: ICloudSandboxAgentHostService,
Expand Down Expand Up @@ -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,
}];
Expand All @@ -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,
Expand Down Expand Up @@ -286,19 +286,18 @@ 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 {
void this._discoverAndSeed();
}
}

/** 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<void> {
/** Records the picked repository on the draft that invoked the picker. */
private async _selectRepository(notificationService: INotificationService, sessionResource: URI | undefined): Promise<void> {
try {
// Without a resource the extension only returns the pick instead of also writing its own option.
const repoNwo = await commandService.executeCommand<string>(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 });
}
Expand All @@ -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<string | undefined> {
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,
Expand Down
Loading
Loading