diff --git a/src/vs/workbench/parts/extensions/common/extensions.ts b/src/vs/workbench/parts/extensions/common/extensions.ts index 9debc8b48d1..106fa829b9a 100644 --- a/src/vs/workbench/parts/extensions/common/extensions.ts +++ b/src/vs/workbench/parts/extensions/common/extensions.ts @@ -53,3 +53,11 @@ export interface IExtensionsService { uninstall(extension: IExtension): TPromise; getInstalled(includeDuplicateVersions?: boolean): TPromise; } + +export var IExtensionTipsService = createDecorator('extensionTipsService'); + +export interface IExtensionTipsService { + serviceId: ServiceIdentifier; + tips: IExtension[]; + onDidChangeTips: Event; +} \ No newline at end of file diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts new file mode 100644 index 00000000000..74beb8a474c --- /dev/null +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import URI from 'vs/base/common/uri'; +import {values, forEach} from 'vs/base/common/collections'; +import {isEmptyObject} from 'vs/base/common/types'; +import {IDisposable, disposeAll} from 'vs/base/common/lifecycle'; +import {TPromise as Promise} from 'vs/base/common/winjs.base'; +import {match} from 'vs/base/common/glob'; +import Event, {Emitter} from 'vs/base/common/event'; +import {IExtensionsService, IGalleryService, IExtensionTipsService, IExtension} from 'vs/workbench/parts/extensions/common/extensions'; +import {IModelService} from 'vs/editor/common/services/modelService'; +import {EventType} from 'vs/editor/common/editorCommon'; + +interface ExtensionMap { + [id: string]: IExtension; +} + +enum ExtensionTipReasons { + // FileExists = 1 + FileOpened = 2, + FileEdited = 3 +} + +class ExtensionTip { + + private _resources: { [uri: string]: ExtensionTipReasons } = Object.create(null); + private _touched = Date.now(); + private _score = -1; + + constructor(public extension: IExtension) { + // + } + + resource(uri: URI, reason: ExtensionTipReasons): boolean { + if (reason !== this._resources[uri.toString()]) { + this._touched = Date.now(); + this._resources[uri.toString()] = Math.max((this._resources[uri.toString()] || 0), reason); + this._score = - 1; + return true; + } + } + + get score() { + if (this._score === -1) { + forEach(this._resources, entry => this._score += entry.value); + } + return this._score; + } + + compareTo(tip: ExtensionTip): number { + if (this === tip) { + return 0; + } + let result = tip.score - this.score; + if (result === 0) { + result = tip._touched - this._touched; + } + return result; + } +} + + +export class ExtensionTipsService implements IExtensionTipsService { + + serviceId: any; + + private _onDidChangeTips: Emitter = new Emitter(); + private _tips: { [id: string]: ExtensionTip } = Object.create(null); + private _toDispose: IDisposable[] = []; + + constructor( + @IExtensionsService private _extensionService: IExtensionsService, + @IGalleryService private _galleryService: IGalleryService, + @IModelService private _modelService: IModelService + ) { + if (this._galleryService.isEnabled()) { + this._init(); + } + } + + dispose() { + this._toDispose = disposeAll(this._toDispose); + } + + get onDidChangeTips(): Event { + return this._onDidChangeTips.event; + } + + get tips(): IExtension[] { + let tips = values(this._tips); + tips.sort((a, b) => a.compareTo(b)); + return tips.map(tip => tip.extension); + } + + private _init() { + + this._galleryService.query().then(extensions => { + let map: ExtensionMap = Object.create(null); + for (let ext of extensions) { + map[`${ext.publisher}.${ext.name}`] = ext; + } + + return this._extensionService.getInstalled().then(installed => { + for (let ext of installed) { + delete map[`${ext.publisher}.${ext.name}`]; + } + return map; + }); + }).then(extensions => { + + // we listen for editor models being added and changed + // when a model is added it gives 2 points, a change gives 3 points + // such that files you type have bigger impact on the suggest + // order than those you only look at + + const modelListener: { [uri: string]: IDisposable } = Object.create(null); + this._toDispose.push({ dispose() { disposeAll(values(modelListener)) } }); + + this._toDispose.push(this._modelService.onModelAdded(model => { + const uri = model.getAssociatedResource(); + this._suggestByResource(extensions, uri, ExtensionTipReasons.FileOpened); + modelListener[uri.toString()] = model.addListener2(EventType.ModelContentChanged2, + () => this._suggestByResource(extensions, uri, ExtensionTipReasons.FileEdited)); + })); + + this._toDispose.push(this._modelService.onModelRemoved(model => { + const subscription = modelListener[model.getAssociatedResource().toString()]; + if (subscription) { + subscription.dispose(); + delete modelListener[model.getAssociatedResource().toString()]; + } + })); + + for (let model of this._modelService.getModels()) { + this._suggestByResource(extensions, model.getAssociatedResource(), ExtensionTipReasons.FileOpened); + } + }); + } + + // --- suggest logic + + private _suggestByResource(extensions: ExtensionMap, uri: URI, reason: ExtensionTipReasons): Promise { + + if (!uri) { + return; + } + + let change = false; + forEach(ExtensionTipsService._extensionByPattern, entry => { + let extension = extensions[entry.key]; + if (extension && match(entry.value, uri.fsPath)) { + let value = this._tips[entry.key]; + if (!value) { + value = this._tips[entry.key] = new ExtensionTip(extension); + } + if (value.resource(uri, reason)) { + change = true; + } + } + }); + + if (change) { + this._onDidChangeTips.fire(undefined); + } + } + + private static _extensionByPattern: { [pattern: string]: string } = { + 'jrieken.vscode-omnisharp': '{**/*.cs,**/project.json,**/global.json,**/*.csproj,**/*.sln}', + 'eg2.tslint': '**/*.ts', + 'dbaeumer.vscode-eslint': '{**/*.js,**/*.es6}', + 'mkaufman.HTMLHint': '{**/*.html,**/*.htm}', + 'seanmcbreen.Spell': '**/*.md', + 'ms-vscode.jscs': '{**/*.js,**/*.es6}', + 'ms-vscode.wordcount': '**/*.md', + 'Ionide.Ionide-fsharp': '{**/*.fsx,**/*.fsi,**/*.fs,**/*.ml,**/*.mli}' + } +} diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts index fd5ab07c693..591cf6f233a 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts @@ -83,6 +83,29 @@ export class ListOutdatedExtensionsAction extends Action { } } +export class ListSuggestedExtensionsAction extends Action { + + static ID = 'workbench.extensions.action.listSuggestedExtensions'; + static LABEL = nls.localize('showSuggestedExtensions', "Show Suggested Extensions"); + + constructor( + id: string, + label: string, + @IExtensionsService private extensionsService: IExtensionsService, + @IQuickOpenService private quickOpenService: IQuickOpenService + ) { + super(id, label, null, true); + } + + public run(): Promise { + return this.quickOpenService.show('ext tips '); + } + + protected isEnabled(): boolean { + return true; + } +} + export class InstallAction extends Action { constructor( diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionsQuickOpen.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsQuickOpen.ts index ddb1a471eb2..1807238d33d 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsQuickOpen.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsQuickOpen.ts @@ -17,7 +17,7 @@ import { since } from 'vs/base/common/dates'; import { matchesContiguousSubString } from 'vs/base/common/filters'; import { QuickOpenHandler } from 'vs/workbench/browser/quickopen'; import { IHighlight } from 'vs/base/parts/quickopen/browser/quickOpenModel'; -import { IExtensionsService, IGalleryService, IExtension } from 'vs/workbench/parts/extensions/common/extensions'; +import { IExtensionsService, IGalleryService, IExtensionTipsService, IExtension } from 'vs/workbench/parts/extensions/common/extensions'; import { InstallAction, UninstallAction } from 'vs/workbench/parts/extensions/electron-browser/extensionsActions'; import { IMessageService } from 'vs/platform/message/common/message'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; @@ -531,4 +531,71 @@ export class OutdatedExtensionsHandler extends QuickOpenHandler { getAutoFocus(searchValue: string): IAutoFocus { return { autoFocusFirstEntry: true }; } -} \ No newline at end of file +} + + +class SuggestedExtensionsModel implements IModel { + + public dataSource = new DataSource(); + public renderer: IRenderer; + public runner: IRunner; + public entries: IExtensionEntry[]; + + constructor( + private suggestedExtensions: IExtension[], + @IInstantiationService instantiationService: IInstantiationService + ) { + this.renderer = instantiationService.createInstance(Renderer); + this.runner = instantiationService.createInstance(InstallRunner); + this.entries = []; + } + + public set input(input: string) { + this.entries = this.suggestedExtensions + .map(extension => ({ extension, highlights: getHighlights(input, extension) })) + .filter(({ highlights }) => !!highlights) + .map(({ extension, highlights }: { extension: IExtension, highlights: IHighlights }) => { + + return { + extension, + highlights, + state: ExtensionState.Uninstalled + }; + }); + } +} + + +export class SuggestedExtensionHandler extends QuickOpenHandler { + + private model: SuggestedExtensionsModel; + + constructor( + @IExtensionTipsService private extensionTipsService: IExtensionTipsService, + @IInstantiationService private instantiationService: IInstantiationService + ) { + super(); + } + + getResults(input: string): TPromise> { + if (!this.model) { + this.model = this.instantiationService.createInstance( + SuggestedExtensionsModel, + this.extensionTipsService.tips); + } + this.model.input = input; + return TPromise.as(this.model); + } + + onClose(canceled: boolean): void { + this.model = null; + } + + getEmptyLabel(input: string): string { + return nls.localize('noSuggestedExtensions', "No suggested extensions"); + } + + getAutoFocus(searchValue: string): IAutoFocus { + return { autoFocusFirstEntry: true }; + } +} diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionsWorkbenchExtension.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsWorkbenchExtension.ts index 79e7ce762c2..a8722bd8fe4 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsWorkbenchExtension.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsWorkbenchExtension.ts @@ -8,7 +8,7 @@ import errors = require('vs/base/common/errors'); import platform = require('vs/platform/platform'); import { Promise } from 'vs/base/common/winjs.base'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { IExtensionsService, IGalleryService } from 'vs/workbench/parts/extensions/common/extensions'; +import { IExtensionsService, IGalleryService, IExtensionTipsService } from 'vs/workbench/parts/extensions/common/extensions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IMessageService } from 'vs/platform/message/common/message'; import Severity from 'vs/base/common/severity'; @@ -16,7 +16,8 @@ import { IWorkspaceContextService } from 'vs/workbench/services/workspace/common import { ReloadWindowAction } from 'vs/workbench/electron-browser/actions'; import wbaregistry = require('vs/workbench/common/actionRegistry'); import { SyncActionDescriptor } from 'vs/platform/actions/common/actions'; -import { ListExtensionsAction, InstallExtensionAction, ListOutdatedExtensionsAction } from './extensionsActions'; +import { ListExtensionsAction, InstallExtensionAction, ListOutdatedExtensionsAction, ListSuggestedExtensionsAction } from './extensionsActions'; +import { ExtensionTipsService } from './extensionTipsService'; import { IQuickOpenRegistry, Extensions, QuickOpenHandlerDescriptor } from 'vs/workbench/browser/quickopen'; import { checkForLegacyExtensionNeeds } from './extensionsAssistant'; import {ipcRenderer as ipc} from 'electron'; @@ -79,6 +80,19 @@ export class ExtensionsWorkbenchExtension implements IWorkbenchContribution { nls.localize('outdatedExtensionsCommands', "Update Outdated Extensions") ) ); + + // add extension tips services + instantiationService.addSingleton(IExtensionTipsService, this.instantiationService.createInstance(ExtensionTipsService)); + actionRegistry.registerWorkbenchAction(new SyncActionDescriptor(ListSuggestedExtensionsAction, ListSuggestedExtensionsAction.ID, ListSuggestedExtensionsAction.LABEL), extensionsCategory); + + (platform.Registry.as(Extensions.Quickopen)).registerQuickOpenHandler( + new QuickOpenHandlerDescriptor( + 'vs/workbench/parts/extensions/electron-browser/extensionsQuickOpen', + 'SuggestedExtensionHandler', + 'ext tips ', + nls.localize('suggestedExtensionsCommands', "Install Suggested Extensions") + ) + ); } }