Overhauls timeline display to stream in results

Implements many API review changes
Fixes #89558
This commit is contained in:
Eric Amodio 2020-02-05 18:16:25 -05:00
parent 1db20bbb60
commit 4cc5b776dc
9 changed files with 361 additions and 296 deletions

View file

@ -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<Uri | undefined>();
get onDidChange(): Event<Uri | undefined> {
private _onDidChange = new EventEmitter<TimelineChangeEvent>();
get onDidChange(): Event<TimelineChangeEvent> {
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;

View file

@ -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<Uri | undefined>;
onDidChange?: Event<TimelineChangeEvent>;
/**
* 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.

View file

@ -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<string, Emitter<URI | undefined>>();
private readonly _providerEmitters = new Map<string, Emitter<TimelineChangeEvent>>();
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<URI | undefined>();
emitters.set(provider.source, onDidChange);
onDidChange = new Emitter<TimelineChangeEvent>();
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 {

View file

@ -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);
}
};

View file

@ -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<TimelineItem[]>;
}
@ -1451,7 +1451,7 @@ export interface ExtHostTunnelServiceShape {
}
export interface ExtHostTimelineShape {
$getTimeline(source: string, uri: UriComponents, token: CancellationToken): Promise<TimelineItem[]>;
$getTimeline(source: string, uri: UriComponents, token: CancellationToken): Promise<TimelineItemWithSource[]>;
}
// --- proxy identifiers

View file

@ -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<TimelineItem[]>;
$getTimeline(id: string, uri: UriComponents, token: vscode.CancellationToken): Promise<TimelineItemWithSource[]>;
}
export const IExtHostTimeline = createDecorator<IExtHostTimeline>('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<TimelineItem[]> {
const provider = this._providers.get(source);
async $getTimeline(id: string, uri: UriComponents, token: vscode.CancellationToken): Promise<TimelineItemWithSource[]> {
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();
});
}

View file

@ -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<TreeElement, FuzzyScore>;
private _tokenSource: CancellationTokenSource | undefined;
private _visibilityDisposables: DisposableStore | undefined;
// private _excludedSources: Set<string> | undefined;
private _items: TimelineItemWithSource[] = [];
private _loadingMessageTimer: NodeJS.Timeout | undefined;
private _pendingRequests = new Map<string, TimelineRequest>();
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();
}

View file

@ -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<URI | undefined>;
export interface TimelineChangeEvent {
id: string;
uri?: URI;
}
provideTimeline(uri: URI, token: CancellationToken): Promise<TimelineItem[]>;
export interface TimelineProvider extends TimelineProviderDescriptor, IDisposable {
onDidChange?: Event<TimelineChangeEvent>;
provideTimeline(uri: URI, token: CancellationToken): Promise<TimelineItemWithSource[]>;
}
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<TimelineItemWithSource[]>;
readonly source: string;
readonly tokenSource: CancellationTokenSource;
readonly uri: URI;
}
export interface ITimelineService {
readonly _serviceBrand: undefined;
onDidChangeProviders: Event<void>;
onDidChangeTimeline: Event<URI | undefined>;
onDidChangeProviders: Event<TimelineProvidersChangeEvent>;
onDidChangeTimeline: Event<TimelineChangeEvent>;
registerTimelineProvider(provider: TimelineProvider): IDisposable;
unregisterTimelineProvider(source: string): void;
unregisterTimelineProvider(id: string): void;
getSources(): string[];
getTimeline(uri: URI, token: CancellationToken): Promise<TimelineItem[]>;
getTimelineRequest(id: string, uri: URI, tokenSource: CancellationTokenSource): TimelineRequest | undefined;
}
const TIMELINE_SERVICE_ID = 'timeline';

View file

@ -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<void>();
readonly onDidChangeProviders: Event<void> = this._onDidChangeProviders.event;
private readonly _onDidChangeProviders = new Emitter<TimelineProvidersChangeEvent>();
readonly onDidChangeProviders: Event<TimelineProvidersChangeEvent> = this._onDidChangeProviders.event;
private readonly _onDidChangeTimeline = new Emitter<URI | undefined>();
readonly onDidChangeTimeline: Event<URI | undefined> = this._onDidChangeTimeline.event;
private readonly _onDidChangeTimeline = new Emitter<TimelineChangeEvent>();
readonly onDidChangeTimeline: Event<TimelineChangeEvent> = this._onDidChangeTimeline.event;
private readonly _providers = new Map<string, TimelineProvider>();
private readonly _providerSubscriptions = new Map<string, IDisposable>();
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<string>) {
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<string, Promise<TimelineItem[] | CancellationErrorWithId<string>>>();
// 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<T, TMapped>(source: Iterable<T> | IterableIterator<T>, mapper: (item: T) => TMapped): Iterable<TMapped> {
// for (const item of source) {
// yield mapper(item);
// }
// }
// class CancellationError<TPromise = any> extends Error {
// constructor(public readonly promise: TPromise, message: string) {
// super(message);
// }
// }
// class CancellationErrorWithId<T, TPromise = any> extends CancellationError<TPromise> {
// constructor(public readonly id: T, promise: TPromise, message: string) {
// super(promise, message);
// }
// }
// function raceAll<TPromise>(
// promises: Promise<TPromise>[],
// timeout?: number
// ): Promise<(TPromise | CancellationError<Promise<TPromise>>)[]>;
// function raceAll<TPromise, T>(
// promises: Map<T, Promise<TPromise>>,
// timeout?: number
// ): Promise<Map<T, TPromise | CancellationErrorWithId<T, Promise<TPromise>>>>;
// function raceAll<TPromise, T>(
// ids: Iterable<T>,
// fn: (id: T) => Promise<TPromise>,
// timeout?: number
// ): Promise<Map<T, TPromise | CancellationErrorWithId<T, Promise<TPromise>>>>;
// async function raceAll<TPromise, T>(
// promisesOrIds: Promise<TPromise>[] | Map<T, Promise<TPromise>> | Iterable<T>,
// timeoutOrFn?: number | ((id: T) => Promise<TPromise>),
// timeout?: number
// ) {
// let promises;
// if (timeoutOrFn !== undefined && typeof timeoutOrFn !== 'number') {
// promises = new Map(
// map<T, [T, Promise<TPromise>]>(promisesOrIds as Iterable<T>, id => [id, timeoutOrFn(id)])
// );
// } else {
// timeout = timeoutOrFn;
// promises = promisesOrIds as Promise<TPromise>[] | Map<T, Promise<TPromise>>;
// }
// if (promises instanceof Map) {
// return new Map(
// await Promise.all(
// map<[T, Promise<TPromise>], Promise<[T, TPromise | CancellationErrorWithId<T, Promise<TPromise>>]>>(
// promises.entries(),
// timeout === undefined
// ? ([id, promise]) => promise.then(p => [id, p])
// : ([id, promise]) =>
// Promise.race([
// promise,
// new Promise<CancellationErrorWithId<T, Promise<TPromise>>>(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<CancellationError<Promise<TPromise>>>(resolve =>
// setTimeout(() => resolve(new CancellationError(p, 'TIMED OUT')), timeout!)
// )
// ])
// )
// );
// }