diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index 35211fd4417..0dbd4a5f469 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -38,6 +38,7 @@ const compilations = [ 'emmet/tsconfig.json', 'extension-editing/tsconfig.json', 'git/tsconfig.json', + 'git-base/tsconfig.json', 'github-authentication/tsconfig.json', 'github/tsconfig.json', 'grunt/tsconfig.json', diff --git a/build/npm/dirs.js b/build/npm/dirs.js index 6c135ea1b13..0f7ab5ff54e 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -17,6 +17,7 @@ exports.dirs = [ 'extensions/emmet', 'extensions/extension-editing', 'extensions/git', + 'extensions/git-base', 'extensions/github', 'extensions/github-authentication', 'extensions/grunt', diff --git a/extensions/git-base/extension.webpack.config.js b/extensions/git-base/extension.webpack.config.js new file mode 100644 index 00000000000..45600607fc5 --- /dev/null +++ b/extensions/git-base/extension.webpack.config.js @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config'); + +module.exports = withDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts' + } +}); diff --git a/extensions/git-base/package.json b/extensions/git-base/package.json index db3cbb7a812..3fc19c587ff 100644 --- a/extensions/git-base/package.json +++ b/extensions/git-base/package.json @@ -8,10 +8,41 @@ "engines": { "vscode": "0.10.x" }, + "categories": [ + "Other" + ], + "activationEvents": [ + "*" + ], + "main": "./out/extension.js", + "icon": "resources/icons/git.png", "scripts": { + "compile": "gulp compile-extension:git-base", + "watch": "gulp watch-extension:git-base", "update-grammar": "node ./build/update-grammars.js" }, + "capabilities": { + "virtualWorkspaces": true, + "untrustedWorkspaces": { + "supported": true + } + }, "contributes": { + "commands": [ + { + "command": "git-base.api.getRemoteSources", + "title": "%command.api.getRemoteSources%", + "category": "Git Base API" + } + ], + "menus": { + "commandPalette": [ + { + "command": "git-base.api.getRemoteSources", + "when": "false" + } + ] + }, "languages": [ { "id": "git-commit", @@ -66,5 +97,15 @@ "path": "./syntaxes/ignore.tmLanguage.json" } ] + }, + "dependencies": { + "vscode-nls": "^4.0.0" + }, + "devDependencies": { + "@types/node": "14.x" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/vscode.git" } } diff --git a/extensions/git-base/package.nls.json b/extensions/git-base/package.nls.json index 4c1acedb648..b501720aaff 100644 --- a/extensions/git-base/package.nls.json +++ b/extensions/git-base/package.nls.json @@ -1,4 +1,5 @@ { "displayName": "Git Base", - "description": "Git static contributions and pickers." + "description": "Git static contributions and pickers.", + "command.api.getRemoteSources": "Get Remote Sources" } diff --git a/extensions/git-base/resources/icons/git.png b/extensions/git-base/resources/icons/git.png new file mode 100644 index 00000000000..51f4ae5404f Binary files /dev/null and b/extensions/git-base/resources/icons/git.png differ diff --git a/extensions/git-base/src/api/api1.ts b/extensions/git-base/src/api/api1.ts new file mode 100644 index 00000000000..7b261f10683 --- /dev/null +++ b/extensions/git-base/src/api/api1.ts @@ -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 { + 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); +} diff --git a/extensions/git-base/src/api/extension.ts b/extensions/git-base/src/api/extension.ts new file mode 100644 index 00000000000..31188c3323b --- /dev/null +++ b/extensions/git-base/src/api/extension.ts @@ -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(); + readonly onDidChangeEnablement: Event = 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); + } +} diff --git a/extensions/git-base/src/api/git-base.d.ts b/extensions/git-base/src/api/git-base.d.ts new file mode 100644 index 00000000000..70ac3b1b972 --- /dev/null +++ b/extensions/git-base/src/api/git-base.d.ts @@ -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; +} + +export interface GitBaseExtension { + + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + + /** + * 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; + getRemoteSources(query?: string): ProviderResult; +} diff --git a/extensions/git-base/src/decorators.ts b/extensions/git-base/src/decorators.ts new file mode 100644 index 00000000000..f7c25b24ef9 --- /dev/null +++ b/extensions/git-base/src/decorators.ts @@ -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(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; + + 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); + }; +} diff --git a/extensions/git-base/src/extension.ts b/extensions/git-base/src/extension.ts new file mode 100644 index 00000000000..17ffb89f82d --- /dev/null +++ b/extensions/git-base/src/extension.ts @@ -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; +} diff --git a/extensions/git-base/src/model.ts b/extensions/git-base/src/model.ts new file mode 100644 index 00000000000..21b48310408 --- /dev/null +++ b/extensions/git-base/src/model.ts @@ -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(); + + private _onDidAddRemoteSourceProvider = new EventEmitter(); + readonly onDidAddRemoteSourceProvider = this._onDidAddRemoteSourceProvider.event; + + private _onDidRemoveRemoteSourceProvider = new EventEmitter(); + 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()]; + } +} diff --git a/extensions/git/src/remoteProvider.ts b/extensions/git-base/src/remoteProvider.ts similarity index 92% rename from extensions/git/src/remoteProvider.ts rename to extensions/git-base/src/remoteProvider.ts index 893caf57f4c..5e9f4359fb3 100644 --- a/extensions/git/src/remoteProvider.ts +++ b/extensions/git-base/src/remoteProvider.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event } from 'vscode'; -import { RemoteSourceProvider } from './api/git'; +import { RemoteSourceProvider } from './api/git-base'; export interface IRemoteSourceProviderRegistry { readonly onDidAddRemoteSourceProvider: Event; readonly onDidRemoveRemoteSourceProvider: Event; - registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + getRemoteProviders(): RemoteSourceProvider[]; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; } diff --git a/extensions/git-base/src/remoteSource.ts b/extensions/git-base/src/remoteSource.ts new file mode 100644 index 00000000000..8f51ad0d399 --- /dev/null +++ b/extensions/git-base/src/remoteSource.ts @@ -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(quickpick: QuickPick): Promise { + const result = await new Promise(c => { + quickpick.onDidAccept(() => c(quickpick.selectedItems[0])); + quickpick.onDidHide(() => c(undefined)); + quickpick.show(); + }); + + quickpick.hide(); + return result; +} + +class RemoteSourceProviderQuickPick { + + private quickpick: QuickPick; + + 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 { + 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 { + this.query(); + const result = await getQuickPickResult(this.quickpick); + return result?.remoteSource; + } +} + +export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise; +export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch: true }): Promise; +export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions = {}): Promise { + 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 { + 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 }; +} diff --git a/extensions/git-base/src/util.ts b/extensions/git-base/src/util.ts new file mode 100644 index 00000000000..9d048ad3dc6 --- /dev/null +++ b/extensions/git-base/src/util.ts @@ -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(promise: Promise): Promise { + return promise.then(() => 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); + } +} diff --git a/extensions/git-base/tsconfig.json b/extensions/git-base/tsconfig.json new file mode 100644 index 00000000000..d7aed1836ee --- /dev/null +++ b/extensions/git-base/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out", + "experimentalDecorators": true, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "include": [ + "src/**/*", + "../../src/vscode-dts/vscode.d.ts" + ] +} diff --git a/extensions/git-base/yarn.lock b/extensions/git-base/yarn.lock new file mode 100644 index 00000000000..22e3e094fdc --- /dev/null +++ b/extensions/git-base/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/node@14.x": + version "14.17.33" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.33.tgz#011ee28e38dc7aee1be032ceadf6332a0ab15b12" + integrity sha512-noEeJ06zbn3lOh4gqe2v7NMGS33jrulfNqYFDjjEbhpDEHR5VTxgYNQSBqBlJIsBJW3uEYDgD6kvMnrrhGzq8g== + +vscode-nls@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-4.1.2.tgz#ca8bf8bb82a0987b32801f9fddfdd2fb9fd3c167" + integrity sha512-7bOHxPsfyuCqmP+hZXscLhiHwe7CSuFE4hyhbs22xPIhQ4jv99FcR4eBzfYYVLP356HNFpdvz63FFb/xw6T4Iw== diff --git a/extensions/git/package.json b/extensions/git/package.json index a26962bab09..b9284c78b8c 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -24,6 +24,9 @@ "*", "onFileSystem:git" ], + "extensionDependencies": [ + "vscode.git-base" + ], "main": "./out/main", "icon": "resources/icons/git.png", "scripts": { diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index fbd6b793f9c..2117bec4ded 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -5,12 +5,13 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, RemoteSourceProvider, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions } from './git'; +import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher } from './git'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands } from 'vscode'; -import { mapEvent } from '../util'; +import { combinedDisposable, mapEvent } from '../util'; import { toGitUri } from '../uri'; -import { pickRemoteSource, PickRemoteSourceOptions } from '../remoteSource'; import { GitExtensionImpl } from './extension'; +import { GitBaseApi } from '../git-base'; +import { PickRemoteSourceOptions } from './git-base'; class ApiInputBox implements InputBox { set value(value: string) { this._inputBox.value = value; } @@ -283,7 +284,18 @@ export class ApiImpl implements API { } registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable { - return this._model.registerRemoteSourceProvider(provider); + const disposables: Disposable[] = []; + + if (provider.publishRepository) { + disposables.push(this._model.registerRemoteSourcePublisher(provider as RemoteSourcePublisher)); + } + disposables.push(GitBaseApi.getAPI().registerRemoteSourceProvider(provider)); + + return combinedDisposable(disposables); + } + + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable { + return this._model.registerRemoteSourcePublisher(publisher); } registerCredentialsProvider(provider: CredentialsProvider): Disposable { @@ -370,11 +382,7 @@ export function registerAPICommands(extension: GitExtensionImpl): Disposable { })); disposables.push(commands.registerCommand('git.api.getRemoteSources', (opts?: PickRemoteSourceOptions) => { - if (!extension.model) { - return; - } - - return pickRemoteSource(extension.model, opts as any); + return commands.executeCommand('git-base.api.getRemoteSources', opts); })); return Disposable.from(...disposables); diff --git a/extensions/git/src/api/git-base.d.ts b/extensions/git/src/api/git-base.d.ts new file mode 100644 index 00000000000..dca68d13071 --- /dev/null +++ b/extensions/git/src/api/git-base.d.ts @@ -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 { + pickRemoteSource(options: PickRemoteSourceOptions): Promise; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; +} + +export interface GitBaseExtension { + + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + + /** + * 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; + getRemoteSources(query?: string): ProviderResult; +} diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 19ac3aa708d..6ab8a38e64c 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -231,6 +231,12 @@ export interface RemoteSourceProvider { publishRepository?(repository: Repository): Promise; } +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + export interface Credentials { readonly username: string; readonly password: string; @@ -265,6 +271,7 @@ export interface API { init(root: Uri): Promise; openRepository(root: Uri): Promise + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index e32b6cc553a..1b6f4aaea43 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import { Command, commands, Disposable, LineChange, MessageOptions, OutputChannel, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider } from 'vscode'; import TelemetryReporter from 'vscode-extension-telemetry'; import * as nls from 'vscode-nls'; -import { Branch, ForcePushMode, GitErrorCodes, Ref, RefType, Status, CommitOptions, RemoteSourceProvider } from './api/git'; +import { Branch, ForcePushMode, GitErrorCodes, Ref, RefType, Status, CommitOptions, RemoteSourcePublisher } from './api/git'; import { Git, Stash } from './git'; import { Model } from './model'; import { Repository, Resource, ResourceGroupType } from './repository'; @@ -392,7 +392,7 @@ export class CommandCenter { async cloneRepository(url?: string, parentPath?: string, options: { recursive?: boolean } = {}): Promise { if (!url || typeof url !== 'string') { - url = await pickRemoteSource(this.model, { + url = await pickRemoteSource({ providerLabel: provider => localize('clonefrom', "Clone from {0}", provider.name), urlLabel: localize('repourl', "Clone from URL") }); @@ -2215,7 +2215,7 @@ export class CommandCenter { @command('git.addRemote', { repository: true }) async addRemote(repository: Repository): Promise { - const url = await pickRemoteSource(this.model, { + const url = await pickRemoteSource({ providerLabel: provider => localize('addfrom', "Add remote from {0}", provider.name), urlLabel: localize('addFrom', "Add remote from URL") }); @@ -2360,19 +2360,19 @@ export class CommandCenter { const remotes = repository.remotes; if (remotes.length === 0) { - const providers = this.model.getRemoteProviders().filter(p => !!p.publishRepository); + const publishers = this.model.getRemoteSourcePublishers(); - if (providers.length === 0) { + if (publishers.length === 0) { window.showWarningMessage(localize('no remotes to publish', "Your repository has no remotes configured to publish to.")); return; } - let provider: RemoteSourceProvider; + let publisher: RemoteSourcePublisher; - if (providers.length === 1) { - provider = providers[0]; + if (publishers.length === 1) { + publisher = publishers[0]; } else { - const picks = providers + const picks = publishers .map(provider => ({ label: (provider.icon ? `$(${provider.icon}) ` : '') + localize('publish to', "Publish to {0}", provider.name), alwaysShow: true, provider })); const placeHolder = localize('pick provider', "Pick a provider to publish the branch '{0}' to:", branchName); const choice = await window.showQuickPick(picks, { placeHolder }); @@ -2381,10 +2381,10 @@ export class CommandCenter { return; } - provider = choice.provider; + publisher = choice.provider; } - await provider.publishRepository!(new ApiRepository(repository)); + await publisher.publishRepository(new ApiRepository(repository)); this.model.firePublishEvent(repository, branchName); return; diff --git a/extensions/git/src/git-base.ts b/extensions/git/src/git-base.ts new file mode 100644 index 00000000000..6cef535cfa5 --- /dev/null +++ b/extensions/git/src/git-base.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { extensions } from 'vscode'; +import { API as GitBaseAPI, GitBaseExtension } from './api/git-base'; + +export class GitBaseApi { + + private static _gitBaseApi: GitBaseAPI | undefined; + + static getAPI(): GitBaseAPI { + if (!this._gitBaseApi) { + const gitBaseExtension = extensions.getExtension('vscode.git-base')!.exports; + const onDidChangeGitBaseExtensionEnablement = (enabled: boolean) => { + this._gitBaseApi = enabled ? gitBaseExtension.getAPI(1) : undefined; + }; + + gitBaseExtension.onDidChangeEnablement(onDidChangeGitBaseExtensionEnablement); + onDidChangeGitBaseExtensionEnablement(gitBaseExtension.enabled); + + if (!this._gitBaseApi) { + throw new Error('vscode.git-base extension is not enabled.'); + } + } + + return this._gitBaseApi; + } +} diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index a57e5f73fac..9cf79aadf82 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -12,11 +12,11 @@ import * as path from 'path'; import * as fs from 'fs'; import * as nls from 'vscode-nls'; import { fromGitUri } from './uri'; -import { APIState as State, RemoteSourceProvider, CredentialsProvider, PushErrorHandler, PublishEvent } from './api/git'; +import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher } from './api/git'; import { Askpass } from './askpass'; -import { IRemoteSourceProviderRegistry } from './remoteProvider'; import { IPushErrorHandlerRegistry } from './pushError'; import { ApiRepository } from './api/api1'; +import { IRemoteSourcePublisherRegistry } from './remotePublisher'; const localize = nls.loadMessageBundle(); @@ -48,7 +48,7 @@ interface OpenRepository extends Disposable { repository: Repository; } -export class Model implements IRemoteSourceProviderRegistry, IPushErrorHandlerRegistry { +export class Model implements IRemoteSourcePublisherRegistry, IPushErrorHandlerRegistry { private _onDidOpenRepository = new EventEmitter(); readonly onDidOpenRepository: Event = this._onDidOpenRepository.event; @@ -95,13 +95,13 @@ export class Model implements IRemoteSourceProviderRegistry, IPushErrorHandlerRe return eventToPromise(filterEvent(this.onDidChangeState, s => s === 'initialized')) as Promise; } - private remoteSourceProviders = new Set(); + private remoteSourcePublishers = new Set(); - private _onDidAddRemoteSourceProvider = new EventEmitter(); - readonly onDidAddRemoteSourceProvider = this._onDidAddRemoteSourceProvider.event; + private _onDidAddRemoteSourcePublisher = new EventEmitter(); + readonly onDidAddRemoteSourcePublisher = this._onDidAddRemoteSourcePublisher.event; - private _onDidRemoveRemoteSourceProvider = new EventEmitter(); - readonly onDidRemoveRemoteSourceProvider = this._onDidRemoveRemoteSourceProvider.event; + private _onDidRemoveRemoteSourcePublisher = new EventEmitter(); + readonly onDidRemoveRemoteSourcePublisher = this._onDidRemoveRemoteSourcePublisher.event; private pushErrorHandlers = new Set(); @@ -496,24 +496,24 @@ export class Model implements IRemoteSourceProviderRegistry, IPushErrorHandlerRe return undefined; } - registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable { - this.remoteSourceProviders.add(provider); - this._onDidAddRemoteSourceProvider.fire(provider); + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable { + this.remoteSourcePublishers.add(publisher); + this._onDidAddRemoteSourcePublisher.fire(publisher); return toDisposable(() => { - this.remoteSourceProviders.delete(provider); - this._onDidRemoveRemoteSourceProvider.fire(provider); + this.remoteSourcePublishers.delete(publisher); + this._onDidRemoveRemoteSourcePublisher.fire(publisher); }); } + getRemoteSourcePublishers(): RemoteSourcePublisher[] { + return [...this.remoteSourcePublishers.values()]; + } + registerCredentialsProvider(provider: CredentialsProvider): Disposable { return this.askpass.registerCredentialsProvider(provider); } - getRemoteProviders(): RemoteSourceProvider[] { - return [...this.remoteSourceProviders.values()]; - } - registerPushErrorHandler(handler: PushErrorHandler): Disposable { this.pushErrorHandlers.add(handler); return toDisposable(() => this.pushErrorHandlers.delete(handler)); diff --git a/extensions/git/src/remotePublisher.ts b/extensions/git/src/remotePublisher.ts new file mode 100644 index 00000000000..1326776cde4 --- /dev/null +++ b/extensions/git/src/remotePublisher.ts @@ -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 { RemoteSourcePublisher } from './api/git'; + +export interface IRemoteSourcePublisherRegistry { + readonly onDidAddRemoteSourcePublisher: Event; + readonly onDidRemoveRemoteSourcePublisher: Event; + + getRemoteSourcePublishers(): RemoteSourcePublisher[]; + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; +} diff --git a/extensions/git/src/remoteSource.ts b/extensions/git/src/remoteSource.ts index cbe437d6af0..4f62181f00c 100644 --- a/extensions/git/src/remoteSource.ts +++ b/extensions/git/src/remoteSource.ts @@ -3,180 +3,11 @@ * 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 } from './api/git'; -import { Model } from './model'; -import { throttle, debounce } from './decorators'; +import { PickRemoteSourceOptions, PickRemoteSourceResult } from './api/git-base'; +import { GitBaseApi } from './git-base'; -const localize = nls.loadMessageBundle(); - -async function getQuickPickResult(quickpick: QuickPick): Promise { - const result = await new Promise(c => { - quickpick.onDidAccept(() => c(quickpick.selectedItems[0])); - quickpick.onDidHide(() => c(undefined)); - quickpick.show(); - }); - - quickpick.hide(); - return result; -} - -class RemoteSourceProviderQuickPick { - - private quickpick: QuickPick; - - 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 { - 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 { - this.query(); - const result = await getQuickPickResult(this.quickpick); - return result?.remoteSource; - } -} - -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 async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise; -export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch: true }): Promise; -export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions = {}): Promise { - 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 { - 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 }; +export async function pickRemoteSource(options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise; +export async function pickRemoteSource(options: PickRemoteSourceOptions & { branch: true }): Promise; +export async function pickRemoteSource(options: PickRemoteSourceOptions = {}): Promise { + return GitBaseApi.getAPI().pickRemoteSource(options); } diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 8f6d5c60d88..27d730b1165 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -16,9 +16,9 @@ import { toGitUri } from './uri'; import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, IDisposable, isDescendant, onceEvent } from './util'; import { IFileWatcher, watch } from './watch'; import { Log, LogLevel } from './log'; -import { IRemoteSourceProviderRegistry } from './remoteProvider'; import { IPushErrorHandlerRegistry } from './pushError'; import { ApiRepository } from './api/api1'; +import { IRemoteSourcePublisherRegistry } from './remotePublisher'; const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); @@ -850,8 +850,8 @@ export class Repository implements Disposable { constructor( private readonly repository: BaseRepository, - remoteSourceProviderRegistry: IRemoteSourceProviderRegistry, private pushErrorHandlerRegistry: IPushErrorHandlerRegistry, + remoteSourcePublisherRegistry: IRemoteSourcePublisherRegistry, globalState: Memento, outputChannel: OutputChannel ) { @@ -959,7 +959,7 @@ export class Repository implements Disposable { } }, null, this.disposables); - const statusBar = new StatusBarCommands(this, remoteSourceProviderRegistry); + const statusBar = new StatusBarCommands(this, remoteSourcePublisherRegistry); this.disposables.push(statusBar); statusBar.onDidChange(() => this._sourceControl.statusBarCommands = statusBar.commands, null, this.disposables); this._sourceControl.statusBarCommands = statusBar.commands; diff --git a/extensions/git/src/statusbar.ts b/extensions/git/src/statusbar.ts index 1de71ad94a0..7cf06e0ed4f 100644 --- a/extensions/git/src/statusbar.ts +++ b/extensions/git/src/statusbar.ts @@ -7,8 +7,8 @@ import { Disposable, Command, EventEmitter, Event, workspace, Uri } from 'vscode import { Repository, Operation } from './repository'; import { anyEvent, dispose, filterEvent } from './util'; import * as nls from 'vscode-nls'; -import { Branch, RemoteSourceProvider } from './api/git'; -import { IRemoteSourceProviderRegistry } from './remoteProvider'; +import { Branch, RemoteSourcePublisher } from './api/git'; +import { IRemoteSourcePublisherRegistry } from './remotePublisher'; const localize = nls.loadMessageBundle(); @@ -44,7 +44,7 @@ interface SyncStatusBarState { readonly isSyncRunning: boolean; readonly hasRemotes: boolean; readonly HEAD: Branch | undefined; - readonly remoteSourceProviders: RemoteSourceProvider[]; + readonly remoteSourcePublishers: RemoteSourcePublisher[]; } class SyncStatusBar { @@ -60,21 +60,20 @@ class SyncStatusBar { this._onDidChange.fire(); } - constructor(private repository: Repository, private remoteSourceProviderRegistry: IRemoteSourceProviderRegistry) { + constructor(private repository: Repository, private remoteSourcePublisherRegistry: IRemoteSourcePublisherRegistry) { this._state = { enabled: true, isSyncRunning: false, hasRemotes: false, HEAD: undefined, - remoteSourceProviders: this.remoteSourceProviderRegistry.getRemoteProviders() - .filter(p => !!p.publishRepository) + remoteSourcePublishers: remoteSourcePublisherRegistry.getRemoteSourcePublishers() }; repository.onDidRunGitStatus(this.onDidRunGitStatus, this, this.disposables); repository.onDidChangeOperations(this.onDidChangeOperations, this, this.disposables); - anyEvent(remoteSourceProviderRegistry.onDidAddRemoteSourceProvider, remoteSourceProviderRegistry.onDidRemoveRemoteSourceProvider) - (this.onDidChangeRemoteSourceProviders, this, this.disposables); + anyEvent(remoteSourcePublisherRegistry.onDidAddRemoteSourcePublisher, remoteSourcePublisherRegistry.onDidRemoveRemoteSourcePublisher) + (this.onDidChangeRemoteSourcePublishers, this, this.disposables); const onEnablementChange = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.enableStatusBarSync')); onEnablementChange(this.updateEnablement, this, this.disposables); @@ -104,11 +103,10 @@ class SyncStatusBar { }; } - private onDidChangeRemoteSourceProviders(): void { + private onDidChangeRemoteSourcePublishers(): void { this.state = { ...this.state, - remoteSourceProviders: this.remoteSourceProviderRegistry.getRemoteProviders() - .filter(p => !!p.publishRepository) + remoteSourcePublishers: this.remoteSourcePublisherRegistry.getRemoteSourcePublishers() }; } @@ -118,12 +116,12 @@ class SyncStatusBar { } if (!this.state.hasRemotes) { - if (this.state.remoteSourceProviders.length === 0) { + if (this.state.remoteSourcePublishers.length === 0) { return; } - const tooltip = this.state.remoteSourceProviders.length === 1 - ? localize('publish to', "Publish to {0}", this.state.remoteSourceProviders[0].name) + const tooltip = this.state.remoteSourcePublishers.length === 1 + ? localize('publish to', "Publish to {0}", this.state.remoteSourcePublishers[0].name) : localize('publish to...', "Publish to..."); return { @@ -188,8 +186,8 @@ export class StatusBarCommands { private checkoutStatusBar: CheckoutStatusBar; private disposables: Disposable[] = []; - constructor(repository: Repository, remoteSourceProviderRegistry: IRemoteSourceProviderRegistry) { - this.syncStatusBar = new SyncStatusBar(repository, remoteSourceProviderRegistry); + constructor(repository: Repository, remoteSourcePublisherRegistry: IRemoteSourcePublisherRegistry) { + this.syncStatusBar = new SyncStatusBar(repository, remoteSourcePublisherRegistry); this.checkoutStatusBar = new CheckoutStatusBar(repository); this.onDidChange = anyEvent(this.syncStatusBar.onDidChange, this.checkoutStatusBar.onDidChange); } diff --git a/extensions/github/package.json b/extensions/github/package.json index 5e3e72a7b1c..e21350c3286 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -16,7 +16,7 @@ "*" ], "extensionDependencies": [ - "vscode.git" + "vscode.git-base" ], "main": "./out/extension.js", "capabilities": { @@ -32,6 +32,14 @@ "title": "Publish to GitHub" } ], + "menus": { + "commandPalette": [ + { + "command": "github.publish", + "when": "git-base.gitEnabled" + } + ] + }, "configuration": [ { "title": "GitHub", diff --git a/extensions/github/src/extension.ts b/extensions/github/src/extension.ts index af3af8900a3..4e25bad1313 100644 --- a/extensions/github/src/extension.ts +++ b/extensions/github/src/extension.ts @@ -3,43 +3,94 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, ExtensionContext, extensions } from 'vscode'; +import { commands, Disposable, ExtensionContext, extensions } from 'vscode'; import { GithubRemoteSourceProvider } from './remoteSourceProvider'; import { GitExtension } from './typings/git'; import { registerCommands } from './commands'; import { GithubCredentialProviderManager } from './credentialProvider'; import { dispose, combinedDisposable } from './util'; import { GithubPushErrorHandler } from './pushErrorHandler'; +import { GitBaseExtension } from './typings/git-base'; +import { GithubRemoteSourcePublisher } from './remoteSourcePublisher'; export function activate(context: ExtensionContext): void { + context.subscriptions.push(initializeGitBaseExtension()); + context.subscriptions.push(initializeGitExtension()); +} + +function initializeGitBaseExtension(): Disposable { const disposables = new Set(); - context.subscriptions.push(combinedDisposable(disposables)); - const init = () => { + const initialize = () => { try { - const gitAPI = gitExtension.getAPI(1); + const gitBaseAPI = gitBaseExtension.getAPI(1); - disposables.add(registerCommands(gitAPI)); - disposables.add(gitAPI.registerRemoteSourceProvider(new GithubRemoteSourceProvider(gitAPI))); - disposables.add(new GithubCredentialProviderManager(gitAPI)); - disposables.add(gitAPI.registerPushErrorHandler(new GithubPushErrorHandler())); - } catch (err) { + disposables.add(gitBaseAPI.registerRemoteSourceProvider(new GithubRemoteSourceProvider())); + } + catch (err) { console.error('Could not initialize GitHub extension'); console.warn(err); } }; - const onDidChangeGitExtensionEnablement = (enabled: boolean) => { + const onDidChangeGitBaseExtensionEnablement = (enabled: boolean) => { if (!enabled) { dispose(disposables); disposables.clear(); } else { - init(); + initialize(); } }; + const gitBaseExtension = extensions.getExtension('vscode.git-base')!.exports; + disposables.add(gitBaseExtension.onDidChangeEnablement(onDidChangeGitBaseExtensionEnablement)); + onDidChangeGitBaseExtensionEnablement(gitBaseExtension.enabled); - const gitExtension = extensions.getExtension('vscode.git')!.exports; - context.subscriptions.push(gitExtension.onDidChangeEnablement(onDidChangeGitExtensionEnablement)); - onDidChangeGitExtensionEnablement(gitExtension.enabled); + return combinedDisposable(disposables); +} + +function initializeGitExtension(): Disposable { + const disposables = new Set(); + + let gitExtension = extensions.getExtension('vscode.git'); + + const initialize = () => { + gitExtension!.activate() + .then(extension => { + const onDidChangeGitExtensionEnablement = (enabled: boolean) => { + if (enabled) { + const gitAPI = extension.getAPI(1); + + disposables.add(registerCommands(gitAPI)); + disposables.add(new GithubCredentialProviderManager(gitAPI)); + disposables.add(gitAPI.registerPushErrorHandler(new GithubPushErrorHandler())); + disposables.add(gitAPI.registerRemoteSourcePublisher(new GithubRemoteSourcePublisher(gitAPI))); + + commands.executeCommand('setContext', 'git-base.gitEnabled', true); + } else { + dispose(disposables); + disposables.clear(); + } + }; + + disposables.add(extension.onDidChangeEnablement(onDidChangeGitExtensionEnablement)); + onDidChangeGitExtensionEnablement(extension.enabled); + }); + }; + + if (gitExtension) { + initialize(); + } else { + const disposable = extensions.onDidChange(() => { + if (!gitExtension && extensions.getExtension('vscode.git')) { + gitExtension = extensions.getExtension('vscode.git'); + initialize(); + + dispose(disposable); + } + }); + disposables.add(disposable); + } + + return combinedDisposable(disposables); } diff --git a/extensions/github/src/remoteSourceProvider.ts b/extensions/github/src/remoteSourceProvider.ts index 072f4e51c8a..5ea6fd21bbc 100644 --- a/extensions/github/src/remoteSourceProvider.ts +++ b/extensions/github/src/remoteSourceProvider.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { API as GitAPI, RemoteSourceProvider, RemoteSource, Repository } from './typings/git'; +import { RemoteSourceProvider, RemoteSource } from './typings/git-base'; import { getOctokit } from './auth'; import { Octokit } from '@octokit/rest'; -import { publishRepository } from './publish'; function parse(url: string): { owner: string, repo: string } | undefined { const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\.git/i.exec(url) @@ -30,8 +29,6 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { private userReposCache: RemoteSource[] = []; - constructor(private gitAPI: GitAPI) { } - async getRemoteSources(query?: string): Promise { const octokit = await getOctokit(); @@ -108,8 +105,4 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { return branches.sort((a, b) => a === defaultBranch ? -1 : b === defaultBranch ? 1 : 0); } - - publishRepository(repository: Repository): Promise { - return publishRepository(this.gitAPI, repository); - } } diff --git a/extensions/github/src/remoteSourcePublisher.ts b/extensions/github/src/remoteSourcePublisher.ts new file mode 100644 index 00000000000..2e6a5d88ead --- /dev/null +++ b/extensions/github/src/remoteSourcePublisher.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { publishRepository } from './publish'; +import { API as GitAPI, RemoteSourcePublisher, Repository } from './typings/git'; + +export class GithubRemoteSourcePublisher implements RemoteSourcePublisher { + readonly name = 'GitHub'; + readonly icon = 'github'; + + constructor(private gitAPI: GitAPI) { } + + publishRepository(repository: Repository): Promise { + return publishRepository(this.gitAPI, repository); + } +} diff --git a/extensions/github/src/typings/git-base.d.ts b/extensions/github/src/typings/git-base.d.ts new file mode 100644 index 00000000000..70ac3b1b972 --- /dev/null +++ b/extensions/github/src/typings/git-base.d.ts @@ -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; +} + +export interface GitBaseExtension { + + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + + /** + * 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; + getRemoteSources(query?: string): ProviderResult; +} diff --git a/extensions/github/src/typings/git.d.ts b/extensions/github/src/typings/git.d.ts index b9a09632b66..25e6352a8d2 100644 --- a/extensions/github/src/typings/git.d.ts +++ b/extensions/github/src/typings/git.d.ts @@ -216,6 +216,12 @@ export interface RemoteSourceProvider { publishRepository?(repository: Repository): Promise; } +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + export interface Credentials { readonly username: string; readonly password: string; @@ -243,6 +249,7 @@ export interface API { getRepository(uri: Uri): Repository | null; init(root: Uri): Promise; + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index f158349d31a..a533b16fce4 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -56,7 +56,7 @@ import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; import { ILogService } from 'vs/platform/log/common/log'; // Extensions that are automatically classified as Programming Language extensions, but should be Feature extensions -const FORCE_FEATURE_EXTENSIONS = ['vscode.git', 'vscode.search-result']; +const FORCE_FEATURE_EXTENSIONS = ['vscode.git', 'vscode.git-base', 'vscode.search-result']; type WorkspaceRecommendationsClassification = { count: { classification: 'SystemMetaData', purpose: 'FeatureInsight', 'isMeasurement': true };