Extract remote source provider registry into the vscode.git-base extension (#137656)

This commit is contained in:
Ladislau Szomoru
2021-11-24 20:48:44 +01:00
committed by GitHub
parent 483d6f15ed
commit bfad20be9d
35 changed files with 946 additions and 258 deletions

View File

@@ -0,0 +1,37 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, commands } from 'vscode';
import { Model } from '../model';
import { pickRemoteSource } from '../remoteSource';
import { GitBaseExtensionImpl } from './extension';
import { API, PickRemoteSourceOptions, PickRemoteSourceResult, RemoteSourceProvider } from './git-base';
export class ApiImpl implements API {
constructor(private _model: Model) { }
pickRemoteSource(options: PickRemoteSourceOptions): Promise<PickRemoteSourceResult | string | undefined> {
return pickRemoteSource(this._model, options as any);
}
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable {
return this._model.registerRemoteSourceProvider(provider);
}
}
export function registerAPICommands(extension: GitBaseExtensionImpl): Disposable {
const disposables: Disposable[] = [];
disposables.push(commands.registerCommand('git-base.api.getRemoteSources', (opts?: PickRemoteSourceOptions) => {
if (!extension.model) {
return;
}
return pickRemoteSource(extension.model, opts as any);
}));
return Disposable.from(...disposables);
}

View File

@@ -0,0 +1,55 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Model } from '../model';
import { GitBaseExtension, API } from './git-base';
import { Event, EventEmitter } from 'vscode';
import { ApiImpl } from './api1';
export class GitBaseExtensionImpl implements GitBaseExtension {
enabled: boolean = false;
private _onDidChangeEnablement = new EventEmitter<boolean>();
readonly onDidChangeEnablement: Event<boolean> = this._onDidChangeEnablement.event;
private _model: Model | undefined = undefined;
set model(model: Model | undefined) {
this._model = model;
const enabled = !!model;
if (this.enabled === enabled) {
return;
}
this.enabled = enabled;
this._onDidChangeEnablement.fire(this.enabled);
}
get model(): Model | undefined {
return this._model;
}
constructor(model?: Model) {
if (model) {
this.enabled = true;
this._model = model;
}
}
getAPI(version: number): API {
if (!this._model) {
throw new Error('Git model not found');
}
if (version !== 1) {
throw new Error(`No API version ${version} found.`);
}
return new ApiImpl(this._model);
}
}

View File

@@ -0,0 +1,60 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, Event, ProviderResult, Uri } from 'vscode';
export { ProviderResult } from 'vscode';
export interface API {
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
pickRemoteSource(options: PickRemoteSourceOptions): Promise<string | PickRemoteSourceResult | undefined>;
}
export interface GitBaseExtension {
readonly enabled: boolean;
readonly onDidChangeEnablement: Event<boolean>;
/**
* Returns a specific API version.
*
* Throws error if git-base extension is disabled. You can listed to the
* [GitBaseExtension.onDidChangeEnablement](#GitBaseExtension.onDidChangeEnablement)
* event to know when the extension becomes enabled/disabled.
*
* @param version Version number.
* @returns API instance
*/
getAPI(version: 1): API;
}
export interface PickRemoteSourceOptions {
readonly providerLabel?: (provider: RemoteSourceProvider) => string;
readonly urlLabel?: string;
readonly providerName?: string;
readonly branch?: boolean; // then result is PickRemoteSourceResult
}
export interface PickRemoteSourceResult {
readonly url: string;
readonly branch?: string;
}
export interface RemoteSource {
readonly name: string;
readonly description?: string;
readonly url: string | string[];
}
export interface RemoteSourceProvider {
readonly name: string;
/**
* Codicon name
*/
readonly icon?: string;
readonly supportsQuery?: boolean;
getBranches?(url: string): ProviderResult<string[]>;
getRemoteSources(query?: string): ProviderResult<RemoteSource[]>;
}

View File

@@ -0,0 +1,69 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { done } from './util';
export function debounce(delay: number): Function {
return decorate((fn, key) => {
const timerKey = `$debounce$${key}`;
return function (this: any, ...args: any[]) {
clearTimeout(this[timerKey]);
this[timerKey] = setTimeout(() => fn.apply(this, args), delay);
};
});
}
export const throttle = decorate(_throttle);
function _throttle<T>(fn: Function, key: string): Function {
const currentKey = `$throttle$current$${key}`;
const nextKey = `$throttle$next$${key}`;
const trigger = function (this: any, ...args: any[]) {
if (this[nextKey]) {
return this[nextKey];
}
if (this[currentKey]) {
this[nextKey] = done(this[currentKey]).then(() => {
this[nextKey] = undefined;
return trigger.apply(this, args);
});
return this[nextKey];
}
this[currentKey] = fn.apply(this, args) as Promise<T>;
const clear = () => this[currentKey] = undefined;
done(this[currentKey]).then(clear, clear);
return this[currentKey];
};
return trigger;
}
function decorate(decorator: (fn: Function, key: string) => Function): Function {
return (_target: any, key: string, descriptor: any) => {
let fnKey: string | null = null;
let fn: Function | null = null;
if (typeof descriptor.value === 'function') {
fnKey = 'value';
fn = descriptor.value;
} else if (typeof descriptor.get === 'function') {
fnKey = 'get';
fn = descriptor.get;
}
if (!fn || !fnKey) {
throw new Error('not supported');
}
descriptor[fnKey] = decorator(fn, key);
};
}

View File

@@ -0,0 +1,16 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ExtensionContext } from 'vscode';
import { registerAPICommands } from './api/api1';
import { GitBaseExtensionImpl } from './api/extension';
import { Model } from './model';
export function activate(context: ExtensionContext): GitBaseExtensionImpl {
const apiImpl = new GitBaseExtensionImpl(new Model());
context.subscriptions.push(registerAPICommands(apiImpl));
return apiImpl;
}

View File

@@ -0,0 +1,34 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventEmitter, Disposable } from 'vscode';
import { toDisposable } from './util';
import { RemoteSourceProvider } from './api/git-base';
import { IRemoteSourceProviderRegistry } from './remoteProvider';
export class Model implements IRemoteSourceProviderRegistry {
private remoteSourceProviders = new Set<RemoteSourceProvider>();
private _onDidAddRemoteSourceProvider = new EventEmitter<RemoteSourceProvider>();
readonly onDidAddRemoteSourceProvider = this._onDidAddRemoteSourceProvider.event;
private _onDidRemoveRemoteSourceProvider = new EventEmitter<RemoteSourceProvider>();
readonly onDidRemoveRemoteSourceProvider = this._onDidRemoveRemoteSourceProvider.event;
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable {
this.remoteSourceProviders.add(provider);
this._onDidAddRemoteSourceProvider.fire(provider);
return toDisposable(() => {
this.remoteSourceProviders.delete(provider);
this._onDidRemoveRemoteSourceProvider.fire(provider);
});
}
getRemoteProviders(): RemoteSourceProvider[] {
return [...this.remoteSourceProviders.values()];
}
}

View File

@@ -0,0 +1,15 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, Event } from 'vscode';
import { RemoteSourceProvider } from './api/git-base';
export interface IRemoteSourceProviderRegistry {
readonly onDidAddRemoteSourceProvider: Event<RemoteSourceProvider>;
readonly onDidRemoveRemoteSourceProvider: Event<RemoteSourceProvider>;
getRemoteProviders(): RemoteSourceProvider[];
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
}

View File

@@ -0,0 +1,170 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { QuickPickItem, window, QuickPick } from 'vscode';
import * as nls from 'vscode-nls';
import { RemoteSourceProvider, RemoteSource, PickRemoteSourceOptions, PickRemoteSourceResult } from './api/git-base';
import { Model } from './model';
import { throttle, debounce } from './decorators';
const localize = nls.loadMessageBundle();
async function getQuickPickResult<T extends QuickPickItem>(quickpick: QuickPick<T>): Promise<T | undefined> {
const result = await new Promise<T | undefined>(c => {
quickpick.onDidAccept(() => c(quickpick.selectedItems[0]));
quickpick.onDidHide(() => c(undefined));
quickpick.show();
});
quickpick.hide();
return result;
}
class RemoteSourceProviderQuickPick {
private quickpick: QuickPick<QuickPickItem & { remoteSource?: RemoteSource }>;
constructor(private provider: RemoteSourceProvider) {
this.quickpick = window.createQuickPick();
this.quickpick.ignoreFocusOut = true;
if (provider.supportsQuery) {
this.quickpick.placeholder = localize('type to search', "Repository name (type to search)");
this.quickpick.onDidChangeValue(this.onDidChangeValue, this);
} else {
this.quickpick.placeholder = localize('type to filter', "Repository name");
}
}
@debounce(300)
private onDidChangeValue(): void {
this.query();
}
@throttle
private async query(): Promise<void> {
this.quickpick.busy = true;
try {
const remoteSources = await this.provider.getRemoteSources(this.quickpick.value) || [];
if (remoteSources.length === 0) {
this.quickpick.items = [{
label: localize('none found', "No remote repositories found."),
alwaysShow: true
}];
} else {
this.quickpick.items = remoteSources.map(remoteSource => ({
label: remoteSource.name,
description: remoteSource.description || (typeof remoteSource.url === 'string' ? remoteSource.url : remoteSource.url[0]),
remoteSource,
alwaysShow: true
}));
}
} catch (err) {
this.quickpick.items = [{ label: localize('error', "$(error) Error: {0}", err.message), alwaysShow: true }];
console.error(err);
} finally {
this.quickpick.busy = false;
}
}
async pick(): Promise<RemoteSource | undefined> {
this.query();
const result = await getQuickPickResult(this.quickpick);
return result?.remoteSource;
}
}
export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise<string | undefined>;
export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch: true }): Promise<PickRemoteSourceResult | undefined>;
export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions = {}): Promise<string | PickRemoteSourceResult | undefined> {
const quickpick = window.createQuickPick<(QuickPickItem & { provider?: RemoteSourceProvider, url?: string })>();
quickpick.ignoreFocusOut = true;
if (options.providerName) {
const provider = model.getRemoteProviders()
.filter(provider => provider.name === options.providerName)[0];
if (provider) {
return await pickProviderSource(provider, options);
}
}
const providers = model.getRemoteProviders()
.map(provider => ({ label: (provider.icon ? `$(${provider.icon}) ` : '') + (options.providerLabel ? options.providerLabel(provider) : provider.name), alwaysShow: true, provider }));
quickpick.placeholder = providers.length === 0
? localize('provide url', "Provide repository URL")
: localize('provide url or pick', "Provide repository URL or pick a repository source.");
const updatePicks = (value?: string) => {
if (value) {
quickpick.items = [{
label: options.urlLabel ?? localize('url', "URL"),
description: value,
alwaysShow: true,
url: value
},
...providers];
} else {
quickpick.items = providers;
}
};
quickpick.onDidChangeValue(updatePicks);
updatePicks();
const result = await getQuickPickResult(quickpick);
if (result) {
if (result.url) {
return result.url;
} else if (result.provider) {
return await pickProviderSource(result.provider, options);
}
}
return undefined;
}
async function pickProviderSource(provider: RemoteSourceProvider, options: PickRemoteSourceOptions = {}): Promise<string | PickRemoteSourceResult | undefined> {
const quickpick = new RemoteSourceProviderQuickPick(provider);
const remote = await quickpick.pick();
let url: string | undefined;
if (remote) {
if (typeof remote.url === 'string') {
url = remote.url;
} else if (remote.url.length > 0) {
url = await window.showQuickPick(remote.url, { ignoreFocusOut: true, placeHolder: localize('pick url', "Choose a URL to clone from.") });
}
}
if (!url || !options.branch) {
return url;
}
if (!provider.getBranches) {
return { url };
}
const branches = await provider.getBranches(url);
if (!branches) {
return { url };
}
const branch = await window.showQuickPick(branches, {
placeHolder: localize('branch name', "Branch name")
});
if (!branch) {
return { url };
}
return { url, branch };
}

View File

@@ -0,0 +1,69 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export interface IDisposable {
dispose(): void;
}
export function toDisposable(dispose: () => void): IDisposable {
return { dispose };
}
export function done<T>(promise: Promise<T>): Promise<void> {
return promise.then<void>(() => undefined);
}
export namespace Versions {
declare type VersionComparisonResult = -1 | 0 | 1;
export interface Version {
major: number;
minor: number;
patch: number;
pre?: string;
}
export function compare(v1: string | Version, v2: string | Version): VersionComparisonResult {
if (typeof v1 === 'string') {
v1 = fromString(v1);
}
if (typeof v2 === 'string') {
v2 = fromString(v2);
}
if (v1.major > v2.major) { return 1; }
if (v1.major < v2.major) { return -1; }
if (v1.minor > v2.minor) { return 1; }
if (v1.minor < v2.minor) { return -1; }
if (v1.patch > v2.patch) { return 1; }
if (v1.patch < v2.patch) { return -1; }
if (v1.pre === undefined && v2.pre !== undefined) { return 1; }
if (v1.pre !== undefined && v2.pre === undefined) { return -1; }
if (v1.pre !== undefined && v2.pre !== undefined) {
return v1.pre.localeCompare(v2.pre) as VersionComparisonResult;
}
return 0;
}
export function from(major: string | number, minor: string | number, patch?: string | number, pre?: string): Version {
return {
major: typeof major === 'string' ? parseInt(major, 10) : major,
minor: typeof minor === 'string' ? parseInt(minor, 10) : minor,
patch: patch === undefined || patch === null ? 0 : typeof patch === 'string' ? parseInt(patch, 10) : patch,
pre: pre,
};
}
export function fromString(version: string): Version {
const [ver, pre] = version.split('-');
const [major, minor, patch] = ver.split('.');
return from(major, minor, patch, pre);
}
}