Browse Source

Improves perf of contributors & details hover

Moves current user to the top of the contributors marked w/ (you) suffix
Adds a max timeout to live share presence requests (250ms)
Only requests the presence of the current user in the contributors view
main
Eric Amodio 5 years ago
parent
commit
4f266fadaa
14 changed files with 234 additions and 111 deletions
  1. +4
    -9
      package.json
  2. +8
    -2
      src/annotations/annotations.ts
  3. +15
    -1
      src/git/gitService.ts
  4. +6
    -1
      src/git/models/contributor.ts
  5. +1
    -0
      src/system.ts
  6. +63
    -0
      src/system/decorators/timeout.ts
  7. +1
    -54
      src/system/function.ts
  8. +55
    -19
      src/system/promise.ts
  9. +15
    -5
      src/views/nodes/contributorNode.ts
  10. +15
    -3
      src/views/nodes/contributorsNode.ts
  11. +2
    -2
      src/views/nodes/resultsCommitsNode.ts
  12. +2
    -2
      src/views/nodes/resultsFilesNode.ts
  13. +2
    -2
      src/views/viewBase.ts
  14. +45
    -11
      src/vsls/vsls.ts

+ 4
- 9
package.json View File

@ -4241,11 +4241,6 @@
],
"editor/title": [
{
"command": "gitlens.exploreRepoAtRevision",
"when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/",
"group": "navigation@-100"
},
{
"command": "gitlens.diffWithWorking",
"when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/",
"group": "navigation@-99"
@ -4870,12 +4865,12 @@
},
{
"command": "gitlens.inviteToLiveShare",
"when": "gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b/",
"when": "gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/",
"group": "inline@1"
},
{
"command": "gitlens.views.contributor.addAuthor",
"when": "viewItem =~ /gitlens:contributor\\b/",
"when": "viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/",
"group": "inline@2"
},
{
@ -4885,12 +4880,12 @@
},
{
"command": "gitlens.inviteToLiveShare",
"when": "gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b/",
"when": "gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/",
"group": "1_gitlens_actions@1"
},
{
"command": "gitlens.views.contributor.addAuthor",
"when": "viewItem =~ /gitlens:contributor\\b/",
"when": "viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/",
"group": "1_gitlens_actions@2"
},
{

+ 8
- 2
src/annotations/annotations.ts View File

@ -21,7 +21,7 @@ import {
GitService,
GitUri
} from '../git/gitService';
import { Objects, Strings } from '../system';
import { debug, Objects, Strings, timeout } from '../system';
import { toRgba } from '../webviews/apps/shared/colors';
export interface ComputedHeatmap {
@ -196,7 +196,7 @@ export class Annotations {
}
const [presence, previousLineDiffUris, remotes] = await Promise.all([
Container.vsls.getContactPresence(commit.email),
Annotations.maybeGetPresence(commit.email).catch(reason => undefined),
commit.isUncommitted ? commit.getPreviousLineDiffUris(uri, editorLine, uri.sha) : undefined,
Container.git.getRemotes(commit.repoPath, { sort: true })
]);
@ -390,4 +390,10 @@ export class Annotations {
return `rgba(${computedHeatmapColor.rgb}, ${(1 - age / 10).toFixed(2)})`;
}
@debug()
@timeout(250)
private static maybeGetPresence(email: string | undefined) {
return Container.vsls.getContactPresence(email);
}
}

+ 15
- 1
src/git/gitService.ts View File

@ -1258,7 +1258,21 @@ export class GitService implements Disposable {
const data = await Git.shortlog(repoPath);
const shortlog = GitShortLogParser.parse(data, repoPath);
return shortlog === undefined ? [] : shortlog.contributors;
if (shortlog == null) return [];
// Mark the current user
const currentUser = await Container.git.getCurrentUser(repoPath);
if (currentUser != null) {
const index = shortlog.contributors.findIndex(
c => currentUser.email === c.email && currentUser.name === c.name
);
if (index !== -1) {
const c = shortlog.contributors[index];
shortlog.contributors.splice(index, 1, new GitContributor(c.repoPath, c.name, c.email, c.count, true));
}
}
return shortlog.contributors;
}
@log()

+ 6
- 1
src/git/models/contributor.ts View File

@ -8,11 +8,16 @@ export class GitContributor {
return contributor instanceof GitContributor;
}
static sort(contributors: GitContributor[]) {
return contributors.sort((a, b) => (a.current ? -1 : 1) - (b.current ? -1 : 1) || b.count - a.count);
}
constructor(
public readonly repoPath: string,
public readonly name: string,
public readonly email: string,
public readonly count: number
public readonly count: number,
public readonly current: boolean = false
) {}
getGravatarUri(fallback: GravatarDefaultStyle, size: number = 16): Uri {

+ 1
- 0
src/system.ts View File

@ -11,6 +11,7 @@ export * from './system/date';
export * from './system/decorators/gate';
export * from './system/decorators/log';
export * from './system/decorators/memoize';
export * from './system/decorators/timeout';
export * from './system/function';
export * from './system/iterable';
export * from './system/object';

+ 63
- 0
src/system/decorators/timeout.ts View File

@ -0,0 +1,63 @@
'use strict';
import { Promises } from '../promise';
// import { Logger } from '../../logger';
// import { Strings } from '../string';
// import { GlyphChars } from '../../constants';
export function timeout<T extends (...arg: any) => any>(timeout: number): any;
export function timeout<T extends (...arg: any) => any>(timeoutFromLastArg: true, defaultTimeout?: number): any;
export function timeout<T extends (...arg: any) => any>(
timeoutOrTimeoutFromLastArg: number | boolean,
defaultTimeout?: number
): any {
let timeout: number | undefined;
let timeoutFromLastArg = false;
if (typeof timeoutOrTimeoutFromLastArg === 'boolean') {
timeoutFromLastArg = timeoutOrTimeoutFromLastArg;
} else {
timeout = timeoutOrTimeoutFromLastArg;
}
return (target: any, key: string, descriptor: PropertyDescriptor) => {
let fn: Function | undefined;
if (typeof descriptor.value === 'function') {
fn = descriptor.value;
}
if (fn == null) throw new Error('Not supported');
descriptor.value = function(this: any, ...args: any[]) {
if (timeoutFromLastArg) {
const lastArg = args[args.length - 1];
if (lastArg != null && typeof lastArg === 'number') {
timeout = lastArg;
} else {
timeout = defaultTimeout;
}
}
const result = fn?.apply(this, args);
if (timeout == null || timeout < 1 || !Promises.is(result)) return result;
// const cc = Logger.getCorrelationContext();
// const start = process.hrtime();
return Promise.race([
result,
// result.then(r => {
// Logger.debug(
// cc,
// `${GlyphChars.Dash} timed out, but completed after ${Strings.getDurationMilliseconds(start)} ms`
// );
// return r;
// }),
new Promise((resolve, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
reject(new Promises.CancellationError(result, `Timed out after ${timeout} ms`));
}, timeout!);
})
]);
};
};
}

+ 1
- 54
src/system/function.ts View File

@ -1,6 +1,6 @@
'use strict';
import { debounce as _debounce, once as _once } from 'lodash-es';
import { CancellationToken, Disposable } from 'vscode';
import { Disposable } from 'vscode';
export interface Deferrable {
cancel(): void;
@ -27,59 +27,6 @@ export namespace Functions {
};
}
export function cancellable<T>(
promise: Thenable<T>,
timeoutOrToken: number | CancellationToken,
options: {
cancelMessage?: string;
onDidCancel?(
resolve: (value?: T | PromiseLike<T> | undefined) => void,
reject: (reason?: any) => void
): void;
} = {}
): Promise<T> {
return new Promise((resolve, reject) => {
let fulfilled = false;
let timer: NodeJS.Timer | undefined;
if (typeof timeoutOrToken === 'number') {
timer = setTimeout(() => {
if (typeof options.onDidCancel === 'function') {
options.onDidCancel(resolve, reject);
} else {
reject(new Error(options.cancelMessage || 'TIMED OUT'));
}
}, timeoutOrToken);
} else {
timeoutOrToken.onCancellationRequested(() => {
if (fulfilled) return;
if (typeof options.onDidCancel === 'function') {
options.onDidCancel(resolve, reject);
} else {
reject(new Error(options.cancelMessage || 'CANCELLED'));
}
});
}
promise.then(
() => {
fulfilled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
resolve(promise);
},
ex => {
fulfilled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
reject(ex);
}
);
});
}
export interface DebounceOptions {
leading?: boolean;
maxWait?: number;

+ 55
- 19
src/system/promise.ts View File

@ -2,29 +2,65 @@
import { CancellationToken } from 'vscode';
export namespace Promises {
export function cancellable<T>(promise: Promise<T>, token: CancellationToken): Promise<T | undefined> {
return new Promise<T | undefined>((resolve, reject) => {
token.onCancellationRequested(() => resolve(undefined));
promise.then(resolve, reject);
});
}
export function is<T>(obj: T | Promise<T>): obj is Promise<T> {
return obj && typeof (obj as Promise<T>).then === 'function';
}
export class TimeoutError<T> extends Error {
constructor(public readonly promise: T) {
super('Promise timed out');
export class CancellationError<T> extends Error {
constructor(public readonly promise: T, message: string) {
super(message);
}
}
export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
setTimeout(() => reject(new TimeoutError(promise)), ms);
export function cancellable<T>(
promise: Thenable<T>,
timeoutOrToken: number | CancellationToken,
options: {
cancelMessage?: string;
onDidCancel?(
resolve: (value?: T | PromiseLike<T> | undefined) => void,
reject: (reason?: any) => void
): void;
} = {}
): Promise<T> {
return new Promise((resolve, reject) => {
let fulfilled = false;
let timer: NodeJS.Timer | undefined;
if (typeof timeoutOrToken === 'number') {
timer = setTimeout(() => {
if (typeof options.onDidCancel === 'function') {
options.onDidCancel(resolve, reject);
} else {
reject(new CancellationError(promise, options.cancelMessage || 'TIMED OUT'));
}
}, timeoutOrToken);
} else {
timeoutOrToken.onCancellationRequested(() => {
if (fulfilled) return;
if (typeof options.onDidCancel === 'function') {
options.onDidCancel(resolve, reject);
} else {
reject(new CancellationError(promise, options.cancelMessage || 'CANCELLED'));
}
});
}
promise.then(resolve, reject);
promise.then(
() => {
fulfilled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
resolve(promise);
},
ex => {
fulfilled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
reject(ex);
}
);
});
}
export function is<T>(obj: T | Promise<T>): obj is Promise<T> {
return obj != null && typeof (obj as Promise<T>).then === 'function';
}
}

+ 15
- 5
src/views/nodes/contributorNode.ts View File

@ -10,6 +10,7 @@ import { insertDateMarkers } from './helpers';
import { CommitNode } from './commitNode';
import { GlyphChars } from '../../constants';
import { RepositoryNode } from './repositoryNode';
import { ContactPresence } from '../../vsls/vsls';
export class ContributorNode extends ViewNode<RepositoriesView> implements PageableViewNode {
static key = ':contributor';
@ -17,7 +18,13 @@ export class ContributorNode extends ViewNode implements Pagea
return `${RepositoryNode.getId(repoPath)}${this.key}(${name}|${email})`;
}
constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly contributor: GitContributor) {
constructor(
uri: GitUri,
view: RepositoriesView,
parent: ViewNode,
public readonly contributor: GitContributor,
private readonly _presenceMap: Map<string, ContactPresence> | undefined
) {
super(uri, view, parent);
}
@ -50,12 +57,15 @@ export class ContributorNode extends ViewNode implements Pagea
return children;
}
async getTreeItem(): Promise<TreeItem> {
const presence = await Container.vsls.getContactPresence(this.contributor.email);
getTreeItem(): TreeItem {
const presence = this._presenceMap?.get(this.contributor.email);
const item = new TreeItem(this.contributor.name, TreeItemCollapsibleState.Collapsed);
const item = new TreeItem(
this.contributor.current ? `${this.contributor.name} (you)` : this.contributor.name,
TreeItemCollapsibleState.Collapsed
);
item.id = this.id;
item.contextValue = ResourceType.Contributor;
item.contextValue = this.contributor.current ? `${ResourceType.Contributor}+current` : ResourceType.Contributor;
item.description = `${
presence != null && presence.status !== 'offline'
? `${presence.statusText} ${GlyphChars.Space}${GlyphChars.Dot}${GlyphChars.Space} `

+ 15
- 3
src/views/nodes/contributorsNode.ts View File

@ -1,12 +1,13 @@
'use strict';
import { TreeItem, TreeItemCollapsibleState } from 'vscode';
import { GitUri, Repository } from '../../git/gitService';
import { GitContributor, GitUri, Repository } from '../../git/gitService';
import { RepositoriesView } from '../repositoriesView';
import { MessageNode } from './common';
import { ContributorNode } from './contributorNode';
import { ResourceType, ViewNode } from './viewNode';
import { Container } from '../../container';
import { RepositoryNode } from './repositoryNode';
import { debug, timeout } from '../../system';
export class ContributorsNode extends ViewNode<RepositoriesView> {
static key = ':contributors';
@ -26,9 +27,10 @@ export class ContributorsNode extends ViewNode {
const contributors = await this.repo.getContributors();
if (contributors.length === 0) return [new MessageNode(this.view, this, 'No contributors could be found.')];
contributors.sort((a, b) => b.count - a.count);
GitContributor.sort(contributors);
const presenceMap = await this.maybeGetPresenceMap(contributors).catch(reason => undefined);
const children = contributors.map(c => new ContributorNode(this.uri, this.view, this, c));
const children = contributors.map(c => new ContributorNode(this.uri, this.view, this, c, presenceMap));
return children;
}
@ -44,4 +46,14 @@ export class ContributorsNode extends ViewNode {
return item;
}
@debug({ args: false })
@timeout(250)
private async maybeGetPresenceMap(contributors: GitContributor[]) {
// Only get presence for the current user, because it is far too slow otherwise
const email = contributors.find(c => c.current)?.email;
if (email == null) return undefined;
return Container.vsls.getContactsPresence([email]);
}
}

+ 2
- 2
src/views/nodes/resultsCommitsNode.ts View File

@ -70,7 +70,7 @@ export class ResultsCommitsNode extends ViewNode implements Pagea
let state;
try {
({ label, log } = await Promises.timeout(this.getCommitsQueryResults(), 100));
({ label, log } = await Promises.cancellable(this.getCommitsQueryResults(), 100));
state =
log == null || log.count === 0
? TreeItemCollapsibleState.None
@ -78,7 +78,7 @@ export class ResultsCommitsNode extends ViewNode implements Pagea
? TreeItemCollapsibleState.Expanded
: TreeItemCollapsibleState.Collapsed;
} catch (ex) {
if (ex instanceof Promises.TimeoutError) {
if (ex instanceof Promises.CancellationError) {
ex.promise.then(() => this.triggerChange(false));
}

+ 2
- 2
src/views/nodes/resultsFilesNode.ts View File

@ -65,11 +65,11 @@ export class ResultsFilesNode extends ViewNode {
let state;
try {
({ label, diff } = await Promises.timeout(this.getFilesQueryResults(), 100));
({ label, diff } = await Promises.cancellable(this.getFilesQueryResults(), 100));
state =
diff == null || diff.length === 0 ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Expanded;
} catch (ex) {
if (ex instanceof Promises.TimeoutError) {
if (ex instanceof Promises.CancellationError) {
ex.promise.then(() => this.triggerChange(false));
}

+ 2
- 2
src/views/viewBase.ts View File

@ -279,7 +279,7 @@ export abstract class ViewBase> implements TreeData
await this.showMoreNodeChildren(node, defaultPageSize);
pagedChildren = await Functions.cancellable(
pagedChildren = await Promises.cancellable(
Promise.resolve(node.getChildren()),
token || 60000,
{
@ -372,7 +372,7 @@ export abstract class ViewBase> implements TreeData
}
}
@debug({ args: { 0: (n: ViewNode) => n.toString() }, singleLine: true })
// @debug({ args: { 0: (n: ViewNode) => n.toString() }, singleLine: true })
getNodeLastKnownLimit(node: PageableViewNode) {
return this._lastKnownLimits.get(node.id);
}

+ 45
- 11
src/vsls/vsls.ts View File

@ -6,6 +6,7 @@ import { Container } from '../container';
import { Logger } from '../logger';
import { VslsGuestService } from './guest';
import { VslsHostService } from './host';
import { debug } from '../system';
export const vslsUriPrefixRegex = /^[/|\\]~(?:\d+?|external)(?:[/|\\]|$)/;
export const vslsUriRootRegex = /^[/|\\]~(?:\d+?|external)$/;
@ -16,6 +17,21 @@ export interface ContactPresence {
}
export type ContactPresenceStatus = 'online' | 'away' | 'busy' | 'dnd' | 'offline';
function contactStatusToPresence(status: string | undefined): ContactPresence {
switch (status) {
case 'available':
return { status: 'online', statusText: 'Available' };
case 'away':
return { status: 'away', statusText: 'Away' };
case 'busy':
return { status: 'busy', statusText: 'Busy' };
case 'doNotDisturb':
return { status: 'dnd', statusText: 'DND' };
default:
return { status: 'offline', statusText: 'Offline' };
}
}
export class VslsController implements Disposable {
private _disposable: Disposable | undefined;
private _guest: VslsGuestService | undefined;
@ -88,6 +104,7 @@ export class VslsController implements Disposable {
setCommandContext(CommandContext.Readonly, value ? true : undefined);
}
@debug()
async getContact(email: string | undefined) {
if (email === undefined) return undefined;
@ -98,22 +115,39 @@ export class VslsController implements Disposable {
return contacts.contacts[email];
}
@debug({
args: {
0: (emails: string[]) => `length=${emails.length}`
}
})
async getContacts(emails: string[]) {
const api = await this._api;
if (api == null) return undefined;
const contacts = await api.getContacts(emails);
return Object.values(contacts.contacts);
}
@debug()
async getContactPresence(email: string | undefined): Promise<ContactPresence | undefined> {
const contact = await this.getContact(email);
if (contact == null) return undefined;
switch (contact.status) {
case 'available':
return { status: 'online', statusText: 'Available' };
case 'away':
return { status: 'away', statusText: 'Away' };
case 'busy':
return { status: 'busy', statusText: 'Busy' };
case 'doNotDisturb':
return { status: 'dnd', statusText: 'DND' };
default:
return { status: 'offline', statusText: 'Offline' };
return contactStatusToPresence(contact.status);
}
@debug({
args: {
0: (emails: string[]) => `length=${emails.length}`
}
})
async getContactsPresence(emails: string[]): Promise<Map<string, ContactPresence> | undefined> {
const contacts = await this.getContacts(emails);
if (contacts == null) return undefined;
return new Map<string, ContactPresence>(
Object.values(contacts).map(c => [c.email, contactStatusToPresence(c.status)])
);
}
async invite(email: string | undefined) {

Loading…
Cancel
Save