Merge pull request #2714 from Microsoft/joh/tips

extension tips
This commit is contained in:
Johannes Rieken 2016-02-05 09:54:10 +01:00
commit 9367be9a58
12 changed files with 485 additions and 8 deletions

View file

@ -5,6 +5,7 @@
import {createDecorator, ServiceIdentifier} from 'vs/platform/instantiation/common/instantiation';
import {IEventEmitter} from 'vs/base/common/eventEmitter';
import Event from 'vs/base/common/event';
import winjs = require('vs/base/common/winjs.base');
export var IConfigurationService = createDecorator<IConfigurationService>('configurationService');
@ -21,7 +22,12 @@ export interface IConfigurationService extends IEventEmitter {
/**
* Returns iff the workspace has configuration or not.
*/
hasWorkspaceConfiguration():boolean;
hasWorkspaceConfiguration(): boolean;
/**
* Event that fires when the configuration changes.
*/
onDidUpdateConfiguration: Event<{ config: any }>
}
export class ConfigurationServiceEventTypes {

View file

@ -19,6 +19,7 @@ import {IWorkspaceContextService} from 'vs/platform/workspace/common/workspace';
import Files = require('vs/platform/files/common/files');
import {IConfigurationRegistry, Extensions} from './configurationRegistry';
import {Registry} from 'vs/platform/platform';
import Event, {fromEventEmitter} from 'vs/base/common/event';
// ---- service abstract implementation
@ -43,6 +44,8 @@ interface ILoadConfigResult {
export abstract class ConfigurationService extends eventEmitter.EventEmitter implements IConfigurationService, lifecycle.IDisposable {
public serviceId = IConfigurationService;
public onDidUpdateConfiguration: Event<{ config: any }>;
protected contextService: IWorkspaceContextService;
protected eventService: IEventService;
protected workspaceSettingsRootFolder: string;
@ -67,6 +70,8 @@ export abstract class ConfigurationService extends eventEmitter.EventEmitter imp
unbind();
subscription.dispose();
}
this.onDidUpdateConfiguration = fromEventEmitter(this, ConfigurationServiceEventTypes.UPDATED);
}
protected abstract resolveContents(resource: uri[]): winjs.TPromise<IContent[]>;
@ -229,6 +234,10 @@ export class NullConfigurationService extends eventEmitter.EventEmitter implemen
public hasWorkspaceConfiguration(): boolean {
return false;
}
public onDidUpdateConfiguration() {
return { dispose() { } };
}
}
export var nullService = new NullConfigurationService();

View file

@ -53,3 +53,11 @@ export interface IExtensionsService {
uninstall(extension: IExtension): TPromise<void>;
getInstalled(includeDuplicateVersions?: boolean): TPromise<IExtension[]>;
}
export var IExtensionTipsService = createDecorator<IExtensionTipsService>('extensionTipsService');
export interface IExtensionTipsService {
serviceId: ServiceIdentifier<any>;
tips: IExtension[];
onDidChangeTips: Event<IExtension[]>;
}

View file

@ -0,0 +1,10 @@
{
"jrieken.vscode-omnisharp": "{**/*.cs,**/project.json,**/global.json,**/*.csproj,**/*.sln}",
"msjsdiag.debugger-for-chrome": "{**/*.ts,**/*.tsx**/*.js,**/*.jsx,**/*.es6}",
"lukehoban.Go": "**/*.go",
"ms-vscode.PowerShell": "{**/*.ps,**/*.ps1}",
"austin.code-gnu-global": "{**/*.c,**/*.cpp,**/*.h}",
"Ionide.Ionide-fsharp": "{**/*.fsx,**/*.fsi,**/*.fs,**/*.ml,**/*.mli}",
"dbaeumer.vscode-eslint": "{**/*.js,**/*.jsx,**/*.es6}",
"eg2.tslint": "{**/*.ts,**/*.tsx}"
}

View file

@ -0,0 +1,227 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/text!vs/workbench/parts/extensions/electron-browser/extensionTips.json';
import URI from 'vs/base/common/uri';
import {onUnexpectedError} from 'vs/base/common/errors';
import {values, forEach} from 'vs/base/common/collections';
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 {IConfigurationService} from 'vs/platform/configuration/common/configuration';
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;
}
interface ExtensionData {
[id: string]: string;
}
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._touched - this._touched;
if (result === 0) {
result = tip.score - this.score;
}
return result;
}
}
export class ExtensionTipsService implements IExtensionTipsService {
serviceId: any;
private _onDidChangeTips: Emitter<IExtension[]> = new Emitter<IExtension[]>();
private _tips: { [id: string]: ExtensionTip } = Object.create(null);
private _disposeOnUpdate: IDisposable[] = [];
private _availableExtensions: Promise<ExtensionMap>;
private _extensionData: Promise<ExtensionData>;
constructor(
@IExtensionsService private _extensionService: IExtensionsService,
@IGalleryService private _galleryService: IGalleryService,
@IModelService private _modelService: IModelService,
@IConfigurationService private _configurationService: IConfigurationService
) {
this._updateState();
}
dispose() {
this._disposeOnUpdate = disposeAll(this._disposeOnUpdate);
}
get onDidChangeTips(): Event<IExtension[]> {
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);
}
// --- internals
private _updateState(): void {
// check with configuration service and then GO
this._disposeOnUpdate = disposeAll(this._disposeOnUpdate);
this._tips = Object.create(null);
this._onDidChangeTips.fire(this.tips);
this._configurationService.loadConfiguration('extensions').then(value => {
if (value && value.showTips === true) {
this._init();
}
}, onUnexpectedError);
// listen for config changes
this._configurationService.onDidUpdateConfiguration(this._updateState, this, this._disposeOnUpdate);
}
private _init():void {
if (!this._galleryService.isEnabled()) {
return;
}
this._extensionData = new Promise((resolve, reject) => {
require(['vs/text!vs/workbench/parts/extensions/electron-browser/extensionTips.json'],
data => resolve(JSON.parse(data)),
reject);
});
this._availableExtensions = this._getAvailableExtensions();
// don't suggest what got installed
this._disposeOnUpdate.push(this._extensionService.onDidInstallExtension(ext => {
const id = `${ext.publisher}.${ext.name}`;
let change = false;
if (delete this._tips[id]) {
change = true;
}
if (change) {
this._onDidChangeTips.fire(this.tips);
}
this._availableExtensions = this._getAvailableExtensions();
}));
// 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._disposeOnUpdate.push({ dispose() { disposeAll(values(modelListener)) } });
this._disposeOnUpdate.push(this._modelService.onModelAdded(model => {
const uri = model.getAssociatedResource();
this._suggestByResource(uri, ExtensionTipReasons.FileOpened);
modelListener[uri.toString()] = model.addListener2(EventType.ModelContentChanged2,
() => this._suggestByResource(uri, ExtensionTipReasons.FileEdited));
}));
this._disposeOnUpdate.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(model.getAssociatedResource(), ExtensionTipReasons.FileOpened);
}
}
private _getAvailableExtensions(): Promise<ExtensionMap> {
return 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;
});
}, () => {
return Object.create(null);
});
}
// --- suggest logic
private _suggestByResource(uri: URI, reason: ExtensionTipReasons): Promise<any> {
if (!uri) {
return;
}
Promise.join<any>([this._availableExtensions, this._extensionData]).then(all => {
let extensions = <ExtensionMap>all[0];
let data = <ExtensionData>all[1];
let change = false;
forEach(data, 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(this.tips);
}
}, () => {
// ignore
});
}
}

View file

@ -3,14 +3,16 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import nls = require('vs/nls');
import platform = require('vs/platform/platform');
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import statusbar = require('vs/workbench/browser/parts/statusbar/statusbar');
import { ExtensionsStatusbarItem } from 'vs/workbench/parts/extensions/electron-browser/extensionsWidgets';
import { ExtensionsStatusbarItem, ExtensionTipsStatusbarItem } from 'vs/workbench/parts/extensions/electron-browser/extensionsWidgets';
import { IGalleryService } from 'vs/workbench/parts/extensions/common/extensions';
import { GalleryService } from 'vs/workbench/parts/extensions/node/vsoGalleryService';
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
import { ExtensionsWorkbenchExtension } from 'vs/workbench/parts/extensions/electron-browser/extensionsWorkbenchExtension';
import ConfigurationRegistry = require('vs/platform/configuration/common/configurationRegistry');
// Register Gallery Service
registerSingleton(IGalleryService, GalleryService);
@ -25,4 +27,24 @@ registerSingleton(IGalleryService, GalleryService);
ExtensionsStatusbarItem,
statusbar.StatusbarAlignment.LEFT,
10 /* Low Priority */
));
));
// Register Statusbar item
(<statusbar.IStatusbarRegistry>platform.Registry.as(statusbar.Extensions.Statusbar)).registerStatusbarItem(new statusbar.StatusbarItemDescriptor(
ExtensionTipsStatusbarItem,
statusbar.StatusbarAlignment.LEFT,
9 /* Low Priority */
));
(<ConfigurationRegistry.IConfigurationRegistry>platform.Registry.as(ConfigurationRegistry.Extensions.Configuration)).registerConfiguration({
id: 'extensions',
type: 'object',
properties: {
'extensions.showTips': {
type: 'boolean',
default: false,
description: nls.localize('extConfig', "Suggest extensions based on changed and open files."),
}
}
});

View file

@ -83,6 +83,29 @@ export class ListOutdatedExtensionsAction extends Action {
}
}
export class ListSuggestedExtensionsAction extends Action {
static ID = 'workbench.extensions.action.listSuggestedExtensions';
static LABEL = nls.localize('showExtensionTips', "Show Extension Tips");
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(

View file

@ -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 };
}
}
}
class SuggestedExtensionsModel implements IModel<IExtensionEntry> {
public dataSource = new DataSource();
public renderer: IRenderer<IExtensionEntry>;
public runner: IRunner<IExtensionEntry>;
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<IModel<IExtensionEntry>> {
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 };
}
}

View file

@ -5,6 +5,7 @@
import nls = require('vs/nls');
import Severity from 'vs/base/common/severity';
import {forEach} from 'vs/base/common/collections';
import errors = require('vs/base/common/errors');
import dom = require('vs/base/browser/dom');
import lifecycle = require('vs/base/common/lifecycle');
@ -13,9 +14,11 @@ import statusbar = require('vs/workbench/browser/parts/statusbar/statusbar');
import { IPluginService, IPluginStatus } from 'vs/platform/plugins/common/plugins';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IMessageService, CloseAction } from 'vs/platform/message/common/message';
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
import { UninstallAction } from 'vs/workbench/parts/extensions/electron-browser/extensionsActions';
import { IQuickOpenService } from 'vs/workbench/services/quickopen/common/quickOpenService';
import { IExtensionsService, IGalleryService, IExtension } from 'vs/workbench/parts/extensions/common/extensions';
import { IExtensionsService, IGalleryService, IExtension, IExtensionTipsService } from 'vs/workbench/parts/extensions/common/extensions';
import { OcticonLabel } from 'vs/base/browser/ui/octiconLabel/octiconLabel';
var $ = dom.emmet;
@ -83,3 +86,69 @@ export class ExtensionsStatusbarItem implements statusbar.IStatusbarItem {
};
}
}
export class ExtensionTipsStatusbarItem implements statusbar.IStatusbarItem {
private static _dontSuggestAgainTimeout = 1000 * 60 * 60 * 24 * 28; // 4 wks
private _domNode: HTMLElement;
private _label: OcticonLabel;
constructor(
@IQuickOpenService private _quickOpenService: IQuickOpenService,
@IExtensionTipsService private _extensionTipsService: IExtensionTipsService,
@IStorageService private _storageService: IStorageService
) {
const previousTips = <{ [id: string]: number }>JSON.parse(this._storageService.get('extensionsAssistant/tips', StorageScope.GLOBAL, '{}'));
// forget previous tips after 28 days
const now = Date.now();
forEach(previousTips, (entry, rm) => {
if (now - entry.value > ExtensionTipsStatusbarItem._dontSuggestAgainTimeout) {
rm();
}
});
function extid(ext: IExtension): string {
return `${ext.publisher}.${ext.name}@${ext.version}`;
};
this._extensionTipsService.onDidChangeTips(tips => {
if (tips.length === 0) {
dom.removeClass(this._domNode, 'active');
return;
}
// check for new tips
let hasNewTips = false;
for (let tip of tips) {
const id = extid(tip);
if (!previousTips[id]) {
previousTips[id] = Date.now();
hasNewTips = true;
}
}
if (hasNewTips) {
dom.addClass(this._domNode, 'active');
this._storageService.store('extensionsAssistant/tips', JSON.stringify(previousTips), StorageScope.GLOBAL);
}
});
}
public render(container: HTMLElement): lifecycle.IDisposable {
this._domNode = document.createElement('a');
this._domNode.className = 'extensions-suggestions';
this._label = new OcticonLabel(this._domNode);
this._label.text = '$(light-bulb) extension tips';
container.appendChild(this._domNode);
return dom.addDisposableListener(this._domNode, 'click', event => this._onClick(event));
}
private _onClick(event: MouseEvent): void {
this._quickOpenService.show('ext tips ').then(() => dom.removeClass(this._domNode, 'active'));
}
}

View file

@ -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';
@ -42,6 +43,9 @@ export class ExtensionsWorkbenchExtension implements IWorkbenchContribution {
this.install(options.extensionsToInstall).done(null, errors.onUnexpectedError);
}
// add service
instantiationService.addSingleton(IExtensionTipsService, this.instantiationService.createInstance(ExtensionTipsService));
const extensionsCategory = nls.localize('extensionsCategory', "Extensions");
const actionRegistry = (<wbaregistry.IWorkbenchActionRegistry> platform.Registry.as(wbaregistry.Extensions.WorkbenchActions));
actionRegistry.registerWorkbenchAction(new SyncActionDescriptor(ListExtensionsAction, ListExtensionsAction.ID, ListExtensionsAction.LABEL), extensionsCategory);
@ -79,6 +83,18 @@ export class ExtensionsWorkbenchExtension implements IWorkbenchContribution {
nls.localize('outdatedExtensionsCommands', "Update Outdated Extensions")
)
);
// add extension tips services
actionRegistry.registerWorkbenchAction(new SyncActionDescriptor(ListSuggestedExtensionsAction, ListSuggestedExtensionsAction.ID, ListSuggestedExtensionsAction.LABEL), extensionsCategory);
(<IQuickOpenRegistry>platform.Registry.as(Extensions.Quickopen)).registerQuickOpenHandler(
new QuickOpenHandlerDescriptor(
'vs/workbench/parts/extensions/electron-browser/extensionsQuickOpen',
'SuggestedExtensionHandler',
'ext tips ',
nls.localize('suggestedExtensionsCommands', "Show Extension Tips")
)
);
}
}

View file

@ -132,3 +132,19 @@
background-size: 14px;
background-position: 4px 50%;
}
.monaco-shell .extensions-suggestions {
visibility: hidden;
padding: 0 5px 0 5px;
-webkit-transition: visibility 250ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition: visibility 250ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.monaco-shell .extensions-suggestions.active {
visibility: inherit;
background-color: rgba(76, 119, 76, 0.9);
}
.monaco-shell .extensions-suggestions > .octicon {
font-size: 14px;
}

View file

@ -514,4 +514,8 @@ export class TestConfigurationService extends EventEmitter.EventEmitter implemen
public hasWorkspaceConfiguration():boolean {
return false;
}
public onDidUpdateConfiguration() {
return { dispose() { } };
}
}