diff --git a/package.json b/package.json index 2509054..374cea1 100644 --- a/package.json +++ b/package.json @@ -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" }, { diff --git a/src/annotations/annotations.ts b/src/annotations/annotations.ts index fa3abda..997691b 100644 --- a/src/annotations/annotations.ts +++ b/src/annotations/annotations.ts @@ -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); + } } diff --git a/src/git/gitService.ts b/src/git/gitService.ts index 3013dcc..2bf0262 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -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() diff --git a/src/git/models/contributor.ts b/src/git/models/contributor.ts index f841d01..8c7117b 100644 --- a/src/git/models/contributor.ts +++ b/src/git/models/contributor.ts @@ -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 { diff --git a/src/system.ts b/src/system.ts index 565a901..c692fa8 100644 --- a/src/system.ts +++ b/src/system.ts @@ -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'; diff --git a/src/system/decorators/timeout.ts b/src/system/decorators/timeout.ts new file mode 100644 index 0000000..b31cbb7 --- /dev/null +++ b/src/system/decorators/timeout.ts @@ -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 any>(timeout: number): any; +export function timeout any>(timeoutFromLastArg: true, defaultTimeout?: number): any; +export function timeout 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!); + }) + ]); + }; + }; +} diff --git a/src/system/function.ts b/src/system/function.ts index 845c85c..df46247 100644 --- a/src/system/function.ts +++ b/src/system/function.ts @@ -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( - promise: Thenable, - timeoutOrToken: number | CancellationToken, - options: { - cancelMessage?: string; - onDidCancel?( - resolve: (value?: T | PromiseLike | undefined) => void, - reject: (reason?: any) => void - ): void; - } = {} - ): Promise { - 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; diff --git a/src/system/promise.ts b/src/system/promise.ts index 260685e..6587784 100644 --- a/src/system/promise.ts +++ b/src/system/promise.ts @@ -2,29 +2,65 @@ import { CancellationToken } from 'vscode'; export namespace Promises { - export function cancellable(promise: Promise, token: CancellationToken): Promise { - return new Promise((resolve, reject) => { - token.onCancellationRequested(() => resolve(undefined)); - - promise.then(resolve, reject); - }); - } - - export function is(obj: T | Promise): obj is Promise { - return obj && typeof (obj as Promise).then === 'function'; - } - - export class TimeoutError extends Error { - constructor(public readonly promise: T) { - super('Promise timed out'); + export class CancellationError extends Error { + constructor(public readonly promise: T, message: string) { + super(message); } } - export function timeout(promise: Promise, ms: number): Promise { - return new Promise((resolve, reject) => { - setTimeout(() => reject(new TimeoutError(promise)), ms); + export function cancellable( + promise: Thenable, + timeoutOrToken: number | CancellationToken, + options: { + cancelMessage?: string; + onDidCancel?( + resolve: (value?: T | PromiseLike | undefined) => void, + reject: (reason?: any) => void + ): void; + } = {} + ): Promise { + 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(obj: T | Promise): obj is Promise { + return obj != null && typeof (obj as Promise).then === 'function'; + } } diff --git a/src/views/nodes/contributorNode.ts b/src/views/nodes/contributorNode.ts index 1aad22a..489ebfd 100644 --- a/src/views/nodes/contributorNode.ts +++ b/src/views/nodes/contributorNode.ts @@ -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 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 | undefined + ) { super(uri, view, parent); } @@ -50,12 +57,15 @@ export class ContributorNode extends ViewNode implements Pagea return children; } - async getTreeItem(): Promise { - 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} ` diff --git a/src/views/nodes/contributorsNode.ts b/src/views/nodes/contributorsNode.ts index 22127ad..b64945c 100644 --- a/src/views/nodes/contributorsNode.ts +++ b/src/views/nodes/contributorsNode.ts @@ -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 { 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]); + } } diff --git a/src/views/nodes/resultsCommitsNode.ts b/src/views/nodes/resultsCommitsNode.ts index dc7afb3..b66fd15 100644 --- a/src/views/nodes/resultsCommitsNode.ts +++ b/src/views/nodes/resultsCommitsNode.ts @@ -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)); } diff --git a/src/views/nodes/resultsFilesNode.ts b/src/views/nodes/resultsFilesNode.ts index 2761fb3..080d3c9 100644 --- a/src/views/nodes/resultsFilesNode.ts +++ b/src/views/nodes/resultsFilesNode.ts @@ -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)); } diff --git a/src/views/viewBase.ts b/src/views/viewBase.ts index 6ecf9a3..a677f07 100644 --- a/src/views/viewBase.ts +++ b/src/views/viewBase.ts @@ -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); } diff --git a/src/vsls/vsls.ts b/src/vsls/vsls.ts index 8a8e817..e118a09 100644 --- a/src/vsls/vsls.ts +++ b/src/vsls/vsls.ts @@ -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 { 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 | undefined> { + const contacts = await this.getContacts(emails); + if (contacts == null) return undefined; + + return new Map( + Object.values(contacts).map(c => [c.email, contactStatusToPresence(c.status)]) + ); } async invite(email: string | undefined) {