From 4cc5b776dcc178f4ca7ecf0932ca570425e4e7af Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Wed, 5 Feb 2020 18:16:25 -0500 Subject: [PATCH] Overhauls timeline display to stream in results Implements many API review changes Fixes #89558 --- extensions/git/src/timelineProvider.ts | 59 ++-- src/vs/vscode.proposed.d.ts | 57 ++-- .../api/browser/mainThreadTimeline.ts | 30 +- .../workbench/api/common/extHost.api.impl.ts | 2 +- .../workbench/api/common/extHost.protocol.ts | 6 +- .../workbench/api/common/extHostTimeline.ts | 41 +-- .../contrib/timeline/browser/timelinePane.ts | 164 ++++++++--- .../contrib/timeline/common/timeline.ts | 40 ++- .../timeline/common/timelineService.ts | 258 +++++++----------- 9 files changed, 361 insertions(+), 296 deletions(-) diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 5559d785a27..1d1033ed191 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -6,7 +6,7 @@ import * as dayjs from 'dayjs'; import * as advancedFormat from 'dayjs/plugin/advancedFormat'; import * as relativeTime from 'dayjs/plugin/relativeTime'; -import { CancellationToken, Disposable, Event, EventEmitter, ThemeIcon, TimelineItem, TimelineProvider, Uri, workspace } from 'vscode'; +import { CancellationToken, Disposable, Event, EventEmitter, ThemeIcon, TimelineItem, TimelineProvider, Uri, workspace, TimelineChangeEvent } from 'vscode'; import { Model } from './model'; import { Repository } from './repository'; import { debounce } from './decorators'; @@ -19,13 +19,13 @@ dayjs.extend(relativeTime); // TODO[ECA]: Localize or use a setting for date format export class GitTimelineProvider implements TimelineProvider { - private _onDidChange = new EventEmitter(); - get onDidChange(): Event { + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { return this._onDidChange.event; } - readonly source = 'git-history'; - readonly sourceDescription = 'Git History'; + readonly id = 'git-history'; + readonly label = 'Git History'; private _disposable: Disposable; @@ -82,19 +82,18 @@ export class GitTimelineProvider implements TimelineProvider { dateFormatter = dayjs(c.authorDate); - return { - id: c.hash, - timestamp: c.authorDate?.getTime() ?? 0, - iconPath: new (ThemeIcon as any)('git-commit'), - label: message, - description: `${dateFormatter.fromNow()} \u2022 ${c.authorName}`, - detail: `${c.authorName} (${c.authorEmail}) \u2014 ${c.hash.substr(0, 8)}\n${dateFormatter.fromNow()} (${dateFormatter.format('MMMM Do, YYYY h:mma')})\n\n${c.message}`, - command: { - title: 'Open Diff', - command: 'git.openDiff', - arguments: [uri, c.hash] - } + const item = new TimelineItem(message, c.authorDate?.getTime() ?? 0); + item.id = c.hash; + item.iconPath = new (ThemeIcon as any)('git-commit'); + item.description = `${dateFormatter.fromNow()} \u2022 ${c.authorName}`; + item.detail = `${c.authorName} (${c.authorEmail}) \u2014 ${c.hash.substr(0, 8)}\n${dateFormatter.fromNow()} (${dateFormatter.format('MMMM Do, YYYY h:mma')})\n\n${c.message}`; + item.command = { + title: 'Open Diff', + command: 'git.openDiff', + arguments: [uri, c.hash] }; + + return item; }); const index = repo.indexGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath); @@ -124,21 +123,19 @@ export class GitTimelineProvider implements TimelineProvider { break; } + const item = new TimelineItem('Staged Changes', date.getTime()); + item.id = '~'; + // TODO[ECA]: Replace with a better icon -- reflecting its status maybe? + item.iconPath = new (ThemeIcon as any)('git-commit'); + item.description = `${dateFormatter.fromNow()} \u2022 You`; + item.detail = `You \u2014 Index\n${dateFormatter.fromNow()} (${dateFormatter.format('MMMM Do, YYYY h:mma')})\n${status}`; + item.command = { + title: 'Open Comparison', + command: 'git.openDiff', + arguments: [uri, '~'] + }; - items.push({ - id: '~', - timestamp: date.getTime(), - // TODO[ECA]: Replace with a better icon -- reflecting its status maybe? - iconPath: new (ThemeIcon as any)('git-commit'), - label: 'Staged Changes', - description: `${dateFormatter.fromNow()} \u2022 You`, - detail: `You \u2014 Index\n${dateFormatter.fromNow()} (${dateFormatter.format('MMMM Do, YYYY h:mma')})\n${status}`, - command: { - title: 'Open Comparison', - command: 'git.openDiff', - arguments: [uri, '~'] - } - }); + items.push(item); } return items; diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index ac6df8714ea..4f3e3e1f214 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -1505,27 +1505,32 @@ declare module 'vscode' { export class TimelineItem { /** - * A timestamp (in milliseconds since 1 January 1970 00:00:00) for when the timeline item occurred + * A timestamp (in milliseconds since 1 January 1970 00:00:00) for when the timeline item occurred. */ timestamp: number; /** - * A human-readable string describing the timeline item. When `falsy`, it is derived from [resourceUri](#TreeItem.resourceUri). + * A human-readable string describing the timeline item. */ label: string; /** - * Optional id for the timeline item. See [TreeItem.id](#TreeItem.id) for more details. + * Optional id for the timeline item. + */ + /** + * Optional id for the timeline item that has to be unique across your timeline source. + * + * If not provided, an id is generated using the timeline item's label. */ id?: string; /** - * The icon path or [ThemeIcon](#ThemeIcon) for the timeline item. See [TreeItem.iconPath](#TreeItem.iconPath) for more details. + * The icon path or [ThemeIcon](#ThemeIcon) for the timeline item. */ iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon; /** - * A human readable string describing less prominent details of the timeline item. See [TreeItem.description](#TreeItem.description) for more details. + * A human readable string describing less prominent details of the timeline item. */ description?: string; @@ -1540,7 +1545,22 @@ declare module 'vscode' { command?: Command; /** - * Context value of the timeline item. See [TreeItem.contextValue](#TreeItem.contextValue) for more details. + * Context value of the timeline item. This can be used to contribute specific actions to the item. + * For example, a timeline item is given a context value as `commit`. When contributing actions to `timeline/item/context` + * using `menus` extension point, you can specify context value for key `timelineItem` in `when` expression like `timelineItem == commit`. + * ``` + * "contributes": { + * "menus": { + * "timeline/item/context": [ + * { + * "command": "extension.copyCommitId", + * "when": "timelineItem == commit" + * } + * ] + * } + * } + * ``` + * This will show the `extension.copyCommitId` action only for items where `contextValue` is `commit`. */ contextValue?: string; @@ -1551,32 +1571,35 @@ declare module 'vscode' { constructor(label: string, timestamp: number); } + export interface TimelineChangeEvent { + /** + * The [uri](#Uri) of the resource for which the timeline changed. + * If the [uri](#Uri) is `undefined` that signals that the timeline source for all resources changed. + */ + uri?: Uri; + } + export interface TimelineProvider { /** * An optional event to signal that the timeline for a source has changed. * To signal that the timeline for all resources (uris) has changed, do not pass any argument or pass `undefined`. */ - onDidChange?: Event; + onDidChange?: Event; /** - * An identifier of the source of the timeline items. This can be used for filtering and/or overriding existing sources. + * An identifier of the source of the timeline items. This can be used to filter sources. */ - source: string; + id: string; /** - * A human-readable string describing the source of the timeline items. This can be as the display label when filtering by sources. + * A human-readable string describing the source of the timeline items. This can be used as the display label when filtering sources. */ - sourceDescription: string; - - /** - * A flag that signals whether this provider can be swapped out (replaced) for another provider using the same [TimelineProvider.source](#TimelineProvider.source). - */ - replaceable?: boolean; + label: string; /** * Provide [timeline items](#TimelineItem) for a [Uri](#Uri). * - * @param uri The uri of the file to provide the timeline for. + * @param uri The [uri](#Uri) of the file to provide the timeline for. * @param token A cancellation token. * @return An array of timeline items or a thenable that resolves to such. The lack of a result * can be signaled by returning `undefined`, `null`, or an empty array. diff --git a/src/vs/workbench/api/browser/mainThreadTimeline.ts b/src/vs/workbench/api/browser/mainThreadTimeline.ts index 19a81a6687d..919e4a18ff5 100644 --- a/src/vs/workbench/api/browser/mainThreadTimeline.ts +++ b/src/vs/workbench/api/browser/mainThreadTimeline.ts @@ -9,12 +9,12 @@ import { URI } from 'vs/base/common/uri'; import { ILogService } from 'vs/platform/log/common/log'; import { MainContext, MainThreadTimelineShape, IExtHostContext, ExtHostTimelineShape, ExtHostContext } from 'vs/workbench/api/common/extHost.protocol'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; -import { ITimelineService, TimelineItem, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline'; +import { ITimelineService, TimelineItem, TimelineProviderDescriptor, TimelineChangeEvent } from 'vs/workbench/contrib/timeline/common/timeline'; @extHostNamedCustomer(MainContext.MainThreadTimeline) export class MainThreadTimeline implements MainThreadTimelineShape { private readonly _proxy: ExtHostTimelineShape; - private readonly _providerEmitters = new Map>(); + private readonly _providerEmitters = new Map>(); constructor( context: IExtHostContext, @@ -29,41 +29,41 @@ export class MainThreadTimeline implements MainThreadTimelineShape { } $registerTimelineProvider(provider: TimelineProviderDescriptor): void { - this.logService.trace(`MainThreadTimeline#registerTimelineProvider: source=${provider.source}`); + this.logService.trace(`MainThreadTimeline#registerTimelineProvider: id=${provider.id}`); const proxy = this._proxy; const emitters = this._providerEmitters; - let onDidChange = emitters.get(provider.source); + let onDidChange = emitters.get(provider.id); if (onDidChange === undefined) { - onDidChange = new Emitter(); - emitters.set(provider.source, onDidChange); + onDidChange = new Emitter(); + emitters.set(provider.id, onDidChange); } this._timelineService.registerTimelineProvider({ ...provider, onDidChange: onDidChange.event, provideTimeline(uri: URI, token: CancellationToken) { - return proxy.$getTimeline(provider.source, uri, token); + return proxy.$getTimeline(provider.id, uri, token); }, dispose() { - emitters.delete(provider.source); + emitters.delete(provider.id); onDidChange?.dispose(); } }); } - $unregisterTimelineProvider(source: string): void { - this.logService.trace(`MainThreadTimeline#unregisterTimelineProvider: source=${source}`); + $unregisterTimelineProvider(id: string): void { + this.logService.trace(`MainThreadTimeline#unregisterTimelineProvider: id=${id}`); - this._timelineService.unregisterTimelineProvider(source); + this._timelineService.unregisterTimelineProvider(id); } - $emitTimelineChangeEvent(source: string, uri: URI | undefined): void { - this.logService.trace(`MainThreadTimeline#emitChangeEvent: source=${source}, uri=${uri?.toString(true)}`); + $emitTimelineChangeEvent(e: TimelineChangeEvent): void { + this.logService.trace(`MainThreadTimeline#emitChangeEvent: id=${e.id}, uri=${e.uri?.toString(true)}`); - const emitter = this._providerEmitters.get(source); - emitter?.fire(uri); + const emitter = this._providerEmitters.get(e.id!); + emitter?.fire(e); } dispose(): void { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 897af14d451..0ce895559ea 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -764,7 +764,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, registerTimelineProvider: (scheme: string, provider: vscode.TimelineProvider) => { checkProposedApiEnabled(extension); - return extHostTimeline.registerTimelineProvider(provider, extHostCommands.converter); + return extHostTimeline.registerTimelineProvider(provider, extension.identifier, extHostCommands.converter); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 40bb47aef3b..3f2b846d933 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -49,7 +49,7 @@ import { SaveReason } from 'vs/workbench/common/editor'; import { ExtensionActivationReason } from 'vs/workbench/api/common/extHostExtensionActivator'; import { TunnelDto } from 'vs/workbench/api/common/extHostTunnelService'; import { TunnelOptions } from 'vs/platform/remote/common/tunnel'; -import { TimelineItem, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline'; +import { TimelineItem, TimelineProviderDescriptor, TimelineChangeEvent, TimelineItemWithSource } from 'vs/workbench/contrib/timeline/common/timeline'; export interface IEnvironment { isExtensionDevelopmentDebug: boolean; @@ -801,7 +801,7 @@ export interface MainThreadTunnelServiceShape extends IDisposable { export interface MainThreadTimelineShape extends IDisposable { $registerTimelineProvider(provider: TimelineProviderDescriptor): void; $unregisterTimelineProvider(source: string): void; - $emitTimelineChangeEvent(source: string, uri: UriComponents | undefined): void; + $emitTimelineChangeEvent(e: TimelineChangeEvent): void; $getTimeline(uri: UriComponents, token: CancellationToken): Promise; } @@ -1451,7 +1451,7 @@ export interface ExtHostTunnelServiceShape { } export interface ExtHostTimelineShape { - $getTimeline(source: string, uri: UriComponents, token: CancellationToken): Promise; + $getTimeline(source: string, uri: UriComponents, token: CancellationToken): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostTimeline.ts b/src/vs/workbench/api/common/extHostTimeline.ts index a09ce2e188c..34fc4e9dee4 100644 --- a/src/vs/workbench/api/common/extHostTimeline.ts +++ b/src/vs/workbench/api/common/extHostTimeline.ts @@ -7,15 +7,16 @@ import * as vscode from 'vscode'; import { UriComponents, URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ExtHostTimelineShape, MainThreadTimelineShape, IMainContext, MainContext } from 'vs/workbench/api/common/extHost.protocol'; -import { TimelineItem, TimelineItemWithSource, TimelineProvider } from 'vs/workbench/contrib/timeline/common/timeline'; +import { TimelineItemWithSource, TimelineProvider } from 'vs/workbench/contrib/timeline/common/timeline'; import { IDisposable, toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; import { CancellationToken } from 'vs/base/common/cancellation'; import { CommandsConverter } from 'vs/workbench/api/common/extHostCommands'; import { ThemeIcon } from 'vs/workbench/api/common/extHostTypes'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; export interface IExtHostTimeline extends ExtHostTimelineShape { readonly _serviceBrand: undefined; - $getTimeline(source: string, uri: UriComponents, token: vscode.CancellationToken): Promise; + $getTimeline(id: string, uri: UriComponents, token: vscode.CancellationToken): Promise; } export const IExtHostTimeline = createDecorator('IExtHostTimeline'); @@ -33,23 +34,24 @@ export class ExtHostTimeline implements IExtHostTimeline { this._proxy = mainContext.getProxy(MainContext.MainThreadTimeline); } - async $getTimeline(source: string, uri: UriComponents, token: vscode.CancellationToken): Promise { - const provider = this._providers.get(source); + async $getTimeline(id: string, uri: UriComponents, token: vscode.CancellationToken): Promise { + const provider = this._providers.get(id); return provider?.provideTimeline(URI.revive(uri), token) ?? []; } - registerTimelineProvider(provider: vscode.TimelineProvider, commandConverter: CommandsConverter): IDisposable { + registerTimelineProvider(provider: vscode.TimelineProvider, extensionId: ExtensionIdentifier, commandConverter: CommandsConverter): IDisposable { const timelineDisposables = new DisposableStore(); - const convertTimelineItem = this.convertTimelineItem(provider.source, commandConverter, timelineDisposables); + const convertTimelineItem = this.convertTimelineItem(provider.id, commandConverter, timelineDisposables); let disposable: IDisposable | undefined; if (provider.onDidChange) { - disposable = provider.onDidChange(this.emitTimelineChangeEvent(provider.source), this); + disposable = provider.onDidChange(this.emitTimelineChangeEvent(provider.id), this); } return this.registerTimelineProviderCore({ ...provider, + onDidChange: undefined, async provideTimeline(uri: URI, token: CancellationToken) { timelineDisposables.clear(); @@ -98,30 +100,29 @@ export class ExtHostTimeline implements IExtHostTimeline { }; } - private emitTimelineChangeEvent(source: string) { - return (uri: vscode.Uri | undefined) => { - this._proxy.$emitTimelineChangeEvent(source, uri); + private emitTimelineChangeEvent(id: string) { + return (e: vscode.TimelineChangeEvent) => { + this._proxy.$emitTimelineChangeEvent({ ...e, id: id }); }; } private registerTimelineProviderCore(provider: TimelineProvider): IDisposable { - // console.log(`ExtHostTimeline#registerTimelineProvider: source=${provider.source}`); + // console.log(`ExtHostTimeline#registerTimelineProvider: id=${provider.id}`); - const existing = this._providers.get(provider.source); - if (existing && !existing.replaceable) { - throw new Error(`Timeline Provider ${provider.source} already exists.`); + const existing = this._providers.get(provider.id); + if (existing) { + throw new Error(`Timeline Provider ${provider.id} already exists.`); } this._proxy.$registerTimelineProvider({ - source: provider.source, - sourceDescription: provider.sourceDescription, - replaceable: provider.replaceable + id: provider.id, + label: provider.label }); - this._providers.set(provider.source, provider); + this._providers.set(provider.id, provider); return toDisposable(() => { - this._providers.delete(provider.source); - this._proxy.$unregisterTimelineProvider(provider.source); + this._providers.delete(provider.id); + this._proxy.$unregisterTimelineProvider(provider.id); provider.dispose(); }); } diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index 8634075234a..114e56caaef 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -20,7 +20,7 @@ import { IContextMenuService } from 'vs/platform/contextview/browser/contextView import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { TimelineItem, ITimelineService } from 'vs/workbench/contrib/timeline/common/timeline'; +import { TimelineItem, ITimelineService, TimelineChangeEvent, TimelineProvidersChangeEvent, TimelineRequest, TimelineItemWithSource } from 'vs/workbench/contrib/timeline/common/timeline'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { SideBySideEditor, toResource } from 'vs/workbench/common/editor'; import { ICommandService } from 'vs/platform/commands/common/commands'; @@ -29,6 +29,7 @@ import { IViewDescriptorService } from 'vs/workbench/common/views'; import { basename } from 'vs/base/common/path'; import { IProgressService } from 'vs/platform/progress/common/progress'; import { VIEWLET_ID } from 'vs/workbench/contrib/files/common/files'; +import { debounce } from 'vs/base/common/decorators'; type TreeElement = TimelineItem; @@ -42,9 +43,14 @@ export class TimelinePane extends ViewPane { private _messageElement!: HTMLDivElement; private _treeElement!: HTMLDivElement; private _tree!: WorkbenchObjectTree; - private _tokenSource: CancellationTokenSource | undefined; private _visibilityDisposables: DisposableStore | undefined; + // private _excludedSources: Set | undefined; + private _items: TimelineItemWithSource[] = []; + private _loadingMessageTimer: NodeJS.Timeout | undefined; + private _pendingRequests = new Map(); + private _uri: URI | undefined; + constructor( options: IViewPaneOptions, @IKeybindingService protected keybindingService: IKeybindingService, @@ -72,16 +78,31 @@ export class TimelinePane extends ViewPane { uri = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER }); } - this.updateUri(uri); + if ((uri?.toString(true) === this._uri?.toString(true) && uri !== undefined) || + // Fallback to match on fsPath if we are dealing with files or git schemes + (uri?.fsPath === this._uri?.fsPath && (uri?.scheme === 'file' || uri?.scheme === 'git') && (this._uri?.scheme === 'file' || this._uri?.scheme === 'git'))) { + return; + } + + this._uri = uri; + this.loadTimeline(); } - private onProvidersChanged() { - this.refresh(); + private onProvidersChanged(e: TimelineProvidersChangeEvent) { + if (e.removed) { + for (const source of e.removed) { + this.replaceItems(source); + } + } + + if (e.added) { + this.loadTimeline(e.added); + } } - private onTimelineChanged(uri: URI | undefined) { - if (uri === undefined || uri.toString(true) !== this._uri?.toString(true)) { - this.refresh(); + private onTimelineChanged(e: TimelineChangeEvent) { + if (e.uri === undefined || e.uri.toString(true) !== this._uri?.toString(true)) { + this.loadTimeline([e.id]); } } @@ -119,53 +140,114 @@ export class TimelinePane extends ViewPane { DOM.clearNode(this._messageElement); } - private async refresh() { - this._tokenSource?.cancel(); - this._tokenSource = new CancellationTokenSource(); + private async loadTimeline(sources?: string[]) { + // If we have no source, we are reseting all sources, so cancel everything in flight and reset caches + if (sources === undefined) { + this._items.length = 0; - let children; - - const uri = this._uri; - // TODO[ECA]: Fix the list of schemes here - if (uri && (uri.scheme === 'file' || uri.scheme === 'git' || uri.scheme === 'gitlens')) { - const messageTimer = setTimeout(() => { - this._tree.setChildren(null, undefined); - this.message = `Loading timeline for ${basename(uri.fsPath)}...`; - }, 500); - - const token = this._tokenSource.token; - const items = await this.progressService.withProgress({ location: VIEWLET_ID }, () => this.timelineService.getTimeline(uri, token)); - - clearTimeout(messageTimer); - - children = items.map(item => ({ element: item })); - - if (children.length === 0) { - this.message = 'No timeline information was provided.'; - } else { - this.message = undefined; + if (this._loadingMessageTimer) { + clearTimeout(this._loadingMessageTimer); + this._loadingMessageTimer = undefined; + } + + for (const { tokenSource } of this._pendingRequests.values()) { + tokenSource.dispose(true); + } + + this._pendingRequests.clear(); + + // TODO[ECA]: Are these the right the list of schemes to exclude? Is there a better way? + if (this._uri && (this._uri.scheme === 'vscode-settings' || this._uri.scheme === 'webview-panel' || this._uri.scheme === 'walkThrough')) { + this.message = 'The active editor cannot provide timeline information.'; + this._tree.setChildren(null, undefined); + + return; + } + + if (this._uri !== undefined) { + this._loadingMessageTimer = setTimeout((uri: URI) => { + if (uri !== this._uri) { + return; + } + + this._tree.setChildren(null, undefined); + this.message = `Loading timeline for ${basename(uri.fsPath)}...`; + }, 500, this._uri); } - } else { - this.message = 'The active editor cannot provide timeline information.'; } - this._tree.setChildren(null, children); + if (this._uri === undefined) { + return; + } + + for (const source of sources ?? this.timelineService.getSources()) { + let request = this._pendingRequests.get(source); + request?.tokenSource.dispose(true); + + request = this.timelineService.getTimelineRequest(source, this._uri, new CancellationTokenSource())!; + + this._pendingRequests.set(source, request); + request.tokenSource.token.onCancellationRequested(() => this._pendingRequests.delete(source)); + + this.handleRequest(request); + } } - private _uri: URI | undefined; + private async handleRequest(request: TimelineRequest) { + let items; + try { + items = await this.progressService.withProgress({ location: VIEWLET_ID }, () => request.items); + } + catch { } - private updateUri(uri: URI | undefined) { - if (uri?.toString(true) === this._uri?.toString(true) && uri !== undefined) { + this._pendingRequests.delete(request.source); + if (request.tokenSource.token.isCancellationRequested || request.uri !== this._uri) { return; } - // Fallback to match on fsPath if we are dealing with files or git schemes - if (uri?.fsPath === this._uri?.fsPath && (uri?.scheme === 'file' || uri?.scheme === 'git') && (this._uri?.scheme === 'file' || this._uri?.scheme === 'git')) { + this.replaceItems(request.source, items); + } + + private replaceItems(source: string, items?: TimelineItemWithSource[]) { + const hasItems = this._items.length !== 0; + + if (items?.length) { + this._items.splice(0, this._items.length, ...this._items.filter(i => i.source !== source), ...items); + this._items.sort((a, b) => (b.timestamp - a.timestamp) || b.source.localeCompare(a.source, undefined, { numeric: true, sensitivity: 'base' })); + } + else if (this._items.length && this._items.some(i => i.source === source)) { + this._items = this._items.filter(i => i.source !== source); + } + else { return; } + // If we have items already and there are other pending requests, debounce for a bit to wait for other requests + if (hasItems && this._pendingRequests.size !== 0) { + this.refreshDebounced(); + } + else { + this.refresh(); + } + } - this._uri = uri; + private refresh() { + if (this._loadingMessageTimer) { + clearTimeout(this._loadingMessageTimer); + this._loadingMessageTimer = undefined; + } + + if (this._items.length === 0) { + this.message = 'No timeline information was provided.'; + } else { + this.message = undefined; + } + + this._tree.setChildren(null, this._items.map(item => ({ element: item }))); + } + + @debounce(500) + private refreshDebounced() { this.refresh(); } diff --git a/src/vs/workbench/contrib/timeline/common/timeline.ts b/src/vs/workbench/contrib/timeline/common/timeline.ts index 7165b4ed015..038aa10ccdb 100644 --- a/src/vs/workbench/contrib/timeline/common/timeline.ts +++ b/src/vs/workbench/contrib/timeline/common/timeline.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from 'vs/base/common/cancellation'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; @@ -32,30 +32,50 @@ export interface TimelineItemWithSource extends TimelineItem { source: string; } -export interface TimelineProvider extends TimelineProviderDescriptor, IDisposable { - onDidChange?: Event; +export interface TimelineChangeEvent { + id: string; + uri?: URI; +} - provideTimeline(uri: URI, token: CancellationToken): Promise; +export interface TimelineProvider extends TimelineProviderDescriptor, IDisposable { + onDidChange?: Event; + + provideTimeline(uri: URI, token: CancellationToken): Promise; } export interface TimelineProviderDescriptor { - source: string; - sourceDescription: string; + id: string; + label: string; - replaceable?: boolean; // selector: DocumentSelector; } +export interface TimelineProvidersChangeEvent { + readonly added?: string[]; + readonly removed?: string[]; +} + +export interface TimelineRequest { + readonly items: Promise; + readonly source: string; + readonly tokenSource: CancellationTokenSource; + readonly uri: URI; +} + export interface ITimelineService { readonly _serviceBrand: undefined; - onDidChangeProviders: Event; - onDidChangeTimeline: Event; + onDidChangeProviders: Event; + onDidChangeTimeline: Event; registerTimelineProvider(provider: TimelineProvider): IDisposable; - unregisterTimelineProvider(source: string): void; + unregisterTimelineProvider(id: string): void; + + getSources(): string[]; getTimeline(uri: URI, token: CancellationToken): Promise; + + getTimelineRequest(id: string, uri: URI, tokenSource: CancellationTokenSource): TimelineRequest | undefined; } const TIMELINE_SERVICE_ID = 'timeline'; diff --git a/src/vs/workbench/contrib/timeline/common/timelineService.ts b/src/vs/workbench/contrib/timeline/common/timelineService.ts index 3b459726197..92990f5396f 100644 --- a/src/vs/workbench/contrib/timeline/common/timelineService.ts +++ b/src/vs/workbench/contrib/timeline/common/timelineService.ts @@ -3,60 +3,95 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from 'vs/base/common/cancellation'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Event, Emitter } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; +// import { basename } from 'vs/base/common/path'; import { URI } from 'vs/base/common/uri'; import { ILogService } from 'vs/platform/log/common/log'; -import { ITimelineService, TimelineProvider, TimelineItem } from './timeline'; +import { ITimelineService, TimelineProvider, TimelineItem, TimelineChangeEvent, TimelineProvidersChangeEvent } from './timeline'; export class TimelineService implements ITimelineService { _serviceBrand: undefined; - private readonly _onDidChangeProviders = new Emitter(); - readonly onDidChangeProviders: Event = this._onDidChangeProviders.event; + private readonly _onDidChangeProviders = new Emitter(); + readonly onDidChangeProviders: Event = this._onDidChangeProviders.event; - private readonly _onDidChangeTimeline = new Emitter(); - readonly onDidChangeTimeline: Event = this._onDidChangeTimeline.event; + private readonly _onDidChangeTimeline = new Emitter(); + readonly onDidChangeTimeline: Event = this._onDidChangeTimeline.event; private readonly _providers = new Map(); private readonly _providerSubscriptions = new Map(); constructor(@ILogService private readonly logService: ILogService) { // this.registerTimelineProvider({ - // source: 'local-history', - // sourceDescription: 'Local History', - // async provideTimeline(uri: URI, token: CancellationToken) { - // return [ - // { - // id: '1', - // label: 'Undo Timeline1', - // description: uri.toString(true), - // date: Date.now() - // }, - // { - // id: '2', - // label: 'Undo Timeline2', - // description: uri.toString(true), - // date: Date.now() - 100 - // } - // ]; + // id: 'local-history', + // label: 'Local History', + // provideTimeline(uri: URI, token: CancellationToken) { + // return new Promise(resolve => setTimeout(() => { + // resolve([ + // { + // id: '1', + // label: 'Slow Timeline1', + // description: basename(uri.fsPath), + // timestamp: Date.now(), + // source: 'local-history' + // }, + // { + // id: '2', + // label: 'Slow Timeline2', + // description: basename(uri.fsPath), + // timestamp: new Date(0).getTime(), + // source: 'local-history' + // } + // ]); + // }, 3000)); + // }, + // dispose() { } + // }); + + // this.registerTimelineProvider({ + // id: 'slow-history', + // label: 'Slow History', + // provideTimeline(uri: URI, token: CancellationToken) { + // return new Promise(resolve => setTimeout(() => { + // resolve([ + // { + // id: '1', + // label: 'VERY Slow Timeline1', + // description: basename(uri.fsPath), + // timestamp: Date.now(), + // source: 'slow-history' + // }, + // { + // id: '2', + // label: 'VERY Slow Timeline2', + // description: basename(uri.fsPath), + // timestamp: new Date(0).getTime(), + // source: 'slow-history' + // } + // ]); + // }, 6000)); // }, // dispose() { } // }); } - async getTimeline(uri: URI, token: CancellationToken, sources?: Set) { + getSources() { + return [...this._providers.keys()]; + } + + async getTimeline(uri: URI, token: CancellationToken, predicate?: (provider: TimelineProvider) => boolean) { this.logService.trace(`TimelineService#getTimeline(${uri.toString(true)})`); const requests: Promise<[string, TimelineItem[]]>[] = []; for (const provider of this._providers.values()) { - if (sources && !sources.has(provider.source)) { + if (!(predicate?.(provider) ?? true)) { continue; } - requests.push(provider.provideTimeline(uri, token).then(p => [provider.source, p])); + requests.push(provider.provideTimeline(uri, token).then(p => [provider.id, p])); } const timelines = await Promise.all(requests); @@ -70,164 +105,71 @@ export class TimelineService implements ITimelineService { timeline.push(...items.map(item => ({ ...item, source: source }))); } - // const requests = new Map>>(); - - // for (const provider of this._providers.values()) { - // if (sources && !sources.has(provider.source)) { - // continue; - // } - - // requests.set(provider.source, provider.provideTimeline(uri, token)); - // } - - // // TODO[ECA]: What should the timeout be for waiting for individual providers? - // const timelines = await raceAll(requests /*, 5000*/); - - // const timeline = []; - // for (const [source, items] of timelines) { - // if (items instanceof CancellationError) { - // this.logService.trace(`TimelineService#getTimeline(${uri.toString(true)}) source=${source} cancelled`); - // continue; - // } - - // if (items.length === 0) { - // continue; - // } - - // timeline.push(...items.map(item => ({ ...item, source: source }))); - // } - timeline.sort((a, b) => b.timestamp - a.timestamp); return timeline; } + getTimelineRequest(id: string, uri: URI, tokenSource: CancellationTokenSource) { + this.logService.trace(`TimelineService#getTimeline(${id}): uri=${uri.toString(true)}`); + + const provider = this._providers.get(id); + if (provider === undefined) { + return undefined; + } + + return { + items: provider.provideTimeline(uri, tokenSource.token) + .then(items => { + items = items.map(item => ({ ...item, source: provider.id })); + items.sort((a, b) => (b.timestamp - a.timestamp) || b.source.localeCompare(a.source, undefined, { numeric: true, sensitivity: 'base' })); + + return items; + }), + source: provider.id, + tokenSource: tokenSource, + uri: uri + }; + } + registerTimelineProvider(provider: TimelineProvider): IDisposable { - this.logService.trace(`TimelineService#registerTimelineProvider: source=${provider.source}`); + this.logService.trace(`TimelineService#registerTimelineProvider: id=${provider.id}`); - const source = provider.source; + const id = provider.id; - const existing = this._providers.get(source); - // For now to deal with https://github.com/microsoft/vscode/issues/89553 allow any overwritting here (still will be blocked in the Extension Host) - // TODO[ECA]: Ultimately will need to figure out a way to unregister providers when the Extension Host restarts/crashes - // if (existing && !existing.replaceable) { - // throw new Error(`Timeline Provider ${source} already exists.`); - // } + const existing = this._providers.get(id); if (existing) { + // For now to deal with https://github.com/microsoft/vscode/issues/89553 allow any overwritting here (still will be blocked in the Extension Host) + // TODO[ECA]: Ultimately will need to figure out a way to unregister providers when the Extension Host restarts/crashes + // throw new Error(`Timeline Provider ${id} already exists.`); try { existing?.dispose(); } catch { } } - this._providers.set(source, provider); + this._providers.set(id, provider); if (provider.onDidChange) { - this._providerSubscriptions.set(source, provider.onDidChange(uri => this.onProviderTimelineChanged(provider.source, uri))); + this._providerSubscriptions.set(id, provider.onDidChange(e => this._onDidChangeTimeline.fire(e))); } - this._onDidChangeProviders.fire(); + this._onDidChangeProviders.fire({ added: [id] }); return { dispose: () => { - this._providers.delete(source); - this._onDidChangeProviders.fire(); + this._providers.delete(id); + this._onDidChangeProviders.fire({ removed: [id] }); } }; } - unregisterTimelineProvider(source: string): void { - this.logService.trace(`TimelineService#unregisterTimelineProvider: source=${source}`); + unregisterTimelineProvider(id: string): void { + this.logService.trace(`TimelineService#unregisterTimelineProvider: id=${id}`); - if (!this._providers.has(source)) { + if (!this._providers.has(id)) { return; } - this._providers.delete(source); - this._providerSubscriptions.delete(source); - this._onDidChangeProviders.fire(); - } - - private onProviderTimelineChanged(source: string, uri: URI | undefined) { - // console.log(`TimelineService.onProviderTimelineChanged: source=${source} uri=${uri?.toString(true)}`); - - this._onDidChangeTimeline.fire(uri); + this._providers.delete(id); + this._providerSubscriptions.delete(id); + this._onDidChangeProviders.fire({ removed: [id] }); } } - -// function* map(source: Iterable | IterableIterator, mapper: (item: T) => TMapped): Iterable { -// for (const item of source) { -// yield mapper(item); -// } -// } - -// class CancellationError extends Error { -// constructor(public readonly promise: TPromise, message: string) { -// super(message); -// } -// } - -// class CancellationErrorWithId extends CancellationError { -// constructor(public readonly id: T, promise: TPromise, message: string) { -// super(promise, message); -// } -// } - -// function raceAll( -// promises: Promise[], -// timeout?: number -// ): Promise<(TPromise | CancellationError>)[]>; -// function raceAll( -// promises: Map>, -// timeout?: number -// ): Promise>>>; -// function raceAll( -// ids: Iterable, -// fn: (id: T) => Promise, -// timeout?: number -// ): Promise>>>; -// async function raceAll( -// promisesOrIds: Promise[] | Map> | Iterable, -// timeoutOrFn?: number | ((id: T) => Promise), -// timeout?: number -// ) { -// let promises; -// if (timeoutOrFn !== undefined && typeof timeoutOrFn !== 'number') { -// promises = new Map( -// map]>(promisesOrIds as Iterable, id => [id, timeoutOrFn(id)]) -// ); -// } else { -// timeout = timeoutOrFn; -// promises = promisesOrIds as Promise[] | Map>; -// } - -// if (promises instanceof Map) { -// return new Map( -// await Promise.all( -// map<[T, Promise], Promise<[T, TPromise | CancellationErrorWithId>]>>( -// promises.entries(), -// timeout === undefined -// ? ([id, promise]) => promise.then(p => [id, p]) -// : ([id, promise]) => -// Promise.race([ -// promise, - -// new Promise>>(resolve => -// setTimeout(() => resolve(new CancellationErrorWithId(id, promise, 'TIMED OUT')), timeout!) -// ) -// ]).then(p => [id, p]) -// ) -// ) -// ); -// } - -// return Promise.all( -// timeout === undefined -// ? promises -// : promises.map(p => -// Promise.race([ -// p, -// new Promise>>(resolve => -// setTimeout(() => resolve(new CancellationError(p, 'TIMED OUT')), timeout!) -// ) -// ]) -// ) -// ); -// }