Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Workspace recommendations based on usage patterns of others on the same workspace #42294

Merged
merged 1 commit into from
Jan 30, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/vs/platform/node/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface IProductConfiguration {
serviceUrl: string;
itemUrl: string;
controlUrl: string;
recommendationsUrl: string;
};
extensionTips: { [id: string]: string; };
extensionImportantTips: { [id: string]: { name: string; pattern: string; }; };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { IChoiceService, IMessageService } from 'vs/platform/message/common/mess
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ShowRecommendedExtensionsAction, InstallWorkspaceRecommendedExtensionsAction, InstallRecommendedExtensionAction } from 'vs/workbench/parts/extensions/browser/extensionsActions';
import Severity from 'vs/base/common/severity';
import { IWorkspaceContextService, IWorkspaceFolder, IWorkspace, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace';
import { IWorkspaceContextService, IWorkspaceFolder, IWorkspace, IWorkspaceFoldersChangeEvent, WorkbenchState } from 'vs/platform/workspace/common/workspace';
import { Schemas } from 'vs/base/common/network';
import { IFileService } from 'vs/platform/files/common/files';
import { IExtensionsConfiguration, ConfigurationKey } from 'vs/workbench/parts/extensions/common/extensions';
Expand All @@ -32,6 +32,10 @@ import { IEnvironmentService } from 'vs/platform/environment/common/environment'
import { guessMimeTypes, MIME_UNKNOWN } from 'vs/base/common/mime';
import { ShowLanguageExtensionsAction } from 'vs/workbench/browser/parts/editor/editorStatus';
import { IExtensionService } from 'vs/platform/extensions/common/extensions';
import { getHashedRemotesFromUri } from 'vs/workbench/parts/stats/node/workspaceStats';
import { IRequestService } from 'vs/platform/request/node/request';
import { asJson } from 'vs/base/node/request';
import { isNumber } from 'vs/base/common/types';

interface IExtensionsContent {
recommendations: string[];
Expand All @@ -42,16 +46,22 @@ const milliSecondsInADay = 1000 * 60 * 60 * 24;
const choiceNever = localize('neverShowAgain', "Don't show again");
const choiceClose = localize('close', "Close");

interface IDynamicWorkspaceRecommendations {
remoteSet: string[];
recommendations: string[];
}

export class ExtensionTipsService extends Disposable implements IExtensionTipsService {

_serviceBrand: any;

private _fileBasedRecommendations: { [id: string]: number; } = Object.create(null);
private _exeBasedRecommendations: { [id: string]: string; } = Object.create(null);
private _availableRecommendations: { [pattern: string]: string[] } = Object.create(null);
private _disposables: IDisposable[] = [];

private _allWorkspaceRecommendedExtensions: string[] = [];
private _dynamicWorkspaceRecommendations: string[] = [];
private _extensionsRecommendationsUrl: string;
private _disposables: IDisposable[] = [];
public promptWorkspaceRecommendationsPromise: TPromise<any>;

constructor(
Expand All @@ -67,14 +77,19 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe
@IMessageService private messageService: IMessageService,
@ITelemetryService private telemetryService: ITelemetryService,
@IEnvironmentService private environmentService: IEnvironmentService,
@IExtensionService private extensionService: IExtensionService
@IExtensionService private extensionService: IExtensionService,
@IRequestService private requestService: IRequestService
) {
super();

if (!this.isEnabled()) {
return;
}

if (product.extensionsGallery && product.extensionsGallery.recommendationsUrl) {
this._extensionsRecommendationsUrl = product.extensionsGallery.recommendationsUrl;
}
this.getDynamicWorkspaceRecommendations();
this._suggestFileBasedRecommendations();

this.promptWorkspaceRecommendationsPromise = this._suggestWorkspaceRecommendations();
Expand All @@ -94,6 +109,7 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe
this._allWorkspaceRecommendedExtensions.forEach(x => output[x.toLowerCase()] = localize('workspaceRecommendation', "This extension is recommended by users of the current workspace."));
Object.keys(this._fileBasedRecommendations).forEach(x => output[x.toLowerCase()] = output[x.toLowerCase()] || localize('fileBasedRecommendation', "This extension is recommended based on the files you recently opened."));
forEach(this._exeBasedRecommendations, entry => output[entry.key.toLowerCase()] = output[entry.key.toLowerCase()] || localize('exeBasedRecommendation', "This extension is recommended because you have {0} installed.", entry.value));
this._dynamicWorkspaceRecommendations.forEach(x => output[x.toLowerCase()] = output[x.toLowerCase()] || localize('dynamicWorkspaceRecommendation', "This extension might interest you because many other users of the current workspace use it."));
return output;
}

Expand Down Expand Up @@ -202,7 +218,13 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe
}

getOtherRecommendations(): string[] {
return Object.keys(this._exeBasedRecommendations);
if (!this._dynamicWorkspaceRecommendations || !this._dynamicWorkspaceRecommendations.length) {
return Object.keys(this._exeBasedRecommendations);
}
const coinToss = Math.round(Math.random());
return distinct(coinToss
? [...Object.keys(this._exeBasedRecommendations), ...this._dynamicWorkspaceRecommendations]
: [...this._dynamicWorkspaceRecommendations, ...Object.keys(this._exeBasedRecommendations)]);
}

getKeymapRecommendations(): string[] {
Expand Down Expand Up @@ -620,6 +642,70 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe
}
}

private getDynamicWorkspaceRecommendations(): TPromise<void> {
if (this.contextService.getWorkbenchState() !== WorkbenchState.FOLDER) {
return TPromise.as(null);
}

const storageKey = 'extensionsAssistant/dynamicWorkspaceRecommendations';
let storedRecommendationsJson = {};
try {
storedRecommendationsJson = JSON.parse(this.storageService.get(storageKey, StorageScope.WORKSPACE, '{}'));
} catch (e) {
this.storageService.remove(storageKey, StorageScope.WORKSPACE);
}

if (Array.isArray(storedRecommendationsJson['recommendations'])
&& isNumber(storedRecommendationsJson['timestamp'])
&& storedRecommendationsJson['timestamp'] > 0
&& (Date.now() - storedRecommendationsJson['timestamp']) / milliSecondsInADay < 14) {
this._dynamicWorkspaceRecommendations = storedRecommendationsJson['recommendations'];
return TPromise.as(null);
}

if (!this._extensionsRecommendationsUrl) {
return TPromise.as(null);
}

return getHashedRemotesFromUri(this.contextService.getWorkspace().folders[0].uri, this.fileService).then(hashedRemotes => {
if (!hashedRemotes || !hashedRemotes.length) {
return null;
}

return new TPromise((c, e) => {
setTimeout(() => {
this.requestService.request({ type: 'GET', url: this._extensionsRecommendationsUrl }).then(context => {
if (context.res.statusCode !== 200) {
return c(null);
}
return asJson(context).then((result) => {
const allRecommendations: IDynamicWorkspaceRecommendations[] = Array.isArray(result['workspaceRecommendations']) ? result['workspaceRecommendations'] : [];
if (!allRecommendations.length) {
return c(null);
}

let foundRemote = false;
for (let i = 0; i < hashedRemotes.length && !foundRemote; i++) {
for (let j = 0; j < allRecommendations.length && !foundRemote; j++) {
if (Array.isArray(allRecommendations[j].remoteSet) && allRecommendations[j].remoteSet.indexOf(hashedRemotes[i]) > -1) {
foundRemote = true;
this._dynamicWorkspaceRecommendations = allRecommendations[j].recommendations || [];
this.storageService.store(storageKey, JSON.stringify({
recommendations: this._dynamicWorkspaceRecommendations,
timestamp: Date.now()
}), StorageScope.WORKSPACE);
}
}
}

return c(null);
});
});
}, 10000);
});
});
}

getKeywordsForExtension(extension: string): string[] {
const keywords = product.extensionKeywords || {};
return keywords[extension] || [];
Expand Down
26 changes: 15 additions & 11 deletions src/vs/workbench/parts/stats/node/workspaceStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,21 @@ export function getRemotes(text: string): string[] {
return remotes;
}

export function getHashedRemotes(text: string): string[] {
export function getHashedRemotesFromConfig(text: string): string[] {
return getRemotes(text).map(r => {
return crypto.createHash('sha1').update(r).digest('hex');
});
}

export function getHashedRemotesFromUri(workspaceUri: URI, fileService: IFileService): TPromise<string[]> {
let path = workspaceUri.path;
let uri = workspaceUri.with({ path: `${path !== '/' ? path : ''}/.git/config` });
return fileService.resolveContent(uri, { acceptTextOnly: true }).then(
content => getHashedRemotesFromConfig(content.value),
err => [] // ignore missing or binary file
);
}

export class WorkspaceStats implements IWorkbenchContribution {
constructor(
@IFileService private fileService: IFileService,
Expand Down Expand Up @@ -329,18 +338,13 @@ export class WorkspaceStats implements IWorkbenchContribution {

private reportRemotes(workspaceUris: URI[]): void {
TPromise.join<string[]>(workspaceUris.map(workspaceUri => {
let path = workspaceUri.path;
let uri = workspaceUri.with({ path: `${path !== '/' ? path : ''}/.git/config` });
return this.fileService.resolveContent(uri, { acceptTextOnly: true }).then(
content => getHashedRemotes(content.value),
err => [] // ignore missing or binary file
);
return getHashedRemotesFromUri(workspaceUri, this.fileService);
})).then(hashedRemotes => {
/* __GDPR__
"workspace.hashedRemotes" : {
"remotes" : { "classification": "CustomerContent", "purpose": "FeatureInsight" }
}
*/
"workspace.hashedRemotes" : {
"remotes" : { "classification": "CustomerContent", "purpose": "FeatureInsight" }
}
*/
this.telemetryService.publicLog('workspace.hashedRemotes', { remotes: hashedRemotes });
}, onUnexpectedError);
}
Expand Down
12 changes: 6 additions & 6 deletions src/vs/workbench/parts/stats/test/workspaceStats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import * as assert from 'assert';
import * as crypto from 'crypto';
import { getDomainsOfRemotes, getRemotes, getHashedRemotes } from 'vs/workbench/parts/stats/node/workspaceStats';
import { getDomainsOfRemotes, getRemotes, getHashedRemotesFromConfig } from 'vs/workbench/parts/stats/node/workspaceStats';

function hash(value: string): string {
return crypto.createHash('sha1').update(value.toString()).digest('hex');
Expand Down Expand Up @@ -90,15 +90,15 @@ suite('Telemetry - WorkspaceStats', () => {
});

test('Single remote hashed', function () {
assert.deepStrictEqual(getHashedRemotes(remote('https://username:[email protected]/username/repository.git')), [hash('github3.com/username/repository.git')]);
assert.deepStrictEqual(getHashedRemotes(remote('ssh://[email protected]/project.git')), [hash('git.server.org/project.git')]);
assert.deepStrictEqual(getHashedRemotes(remote('[email protected]:project.git')), [hash('git.server.org/project.git')]);
assert.deepStrictEqual(getHashedRemotes(remote('/opt/git/project.git')), []);
assert.deepStrictEqual(getHashedRemotesFromConfig(remote('https://username:[email protected]/username/repository.git')), [hash('github3.com/username/repository.git')]);
assert.deepStrictEqual(getHashedRemotesFromConfig(remote('ssh://[email protected]/project.git')), [hash('git.server.org/project.git')]);
assert.deepStrictEqual(getHashedRemotesFromConfig(remote('[email protected]:project.git')), [hash('git.server.org/project.git')]);
assert.deepStrictEqual(getHashedRemotesFromConfig(remote('/opt/git/project.git')), []);
});

test('Multiple remotes hashed', function () {
const config = ['https://github.com/Microsoft/vscode.git', 'https://git.example.com/gitproject.git'].map(remote).join(' ');
assert.deepStrictEqual(getHashedRemotes(config), [hash('github.com/Microsoft/vscode.git'), hash('git.example.com/gitproject.git')]);
assert.deepStrictEqual(getHashedRemotesFromConfig(config), [hash('github.com/Microsoft/vscode.git'), hash('git.example.com/gitproject.git')]);
});

function remote(url: string): string {
Expand Down