Lots of timeline related changes, below

UI:
Adds Refresh icon to view title
Adds "Load more" entry at the end of the list for paging

API:
Restructures api around cursors
Renames TimelineCursor to generic TimelineOptions for more flexibility
Adds paging object to Timeline for clearer paging usage
Changes cursors to be strings, and explicit before and after cursors
Allows limit to take a cursor, so we can reload current data set
Clarifies id and fallback to timestamp
Adds reset flag to TimelineChangeEvent for providers to reset caching

Git provider:
Orders and returns commit date as the timestamp
Supports limit of a cursor (using rev-list --count)
Stops returning working/index changes when paging
Forcably resets cached data when changes are detected (naive for now)
This commit is contained in:
Eric Amodio 2020-02-24 15:48:26 -05:00
parent d226035b73
commit d46c8a8c3c
11 changed files with 636 additions and 187 deletions

View file

@ -50,8 +50,13 @@ interface MutableRemote extends Remote {
* Log file options.
*/
export interface LogFileOptions {
/** Max number of log entries to retrieve. If not specified, the default is 32. */
readonly maxEntries?: number;
/** Optional. The maximum number of log entries to retrieve. */
readonly maxEntries?: number | string;
/** Optional. The Git sha (hash) to start retrieving log entries from. */
readonly hash?: string;
/** Optional. Specifies whether to start retrieving log entries in reverse order. */
readonly reverse?: boolean;
readonly sortByAuthorDate?: boolean;
}
function parseVersion(raw: string): string {
@ -817,8 +822,26 @@ export class Repository {
}
async logFile(uri: Uri, options?: LogFileOptions): Promise<Commit[]> {
const maxEntries = options?.maxEntries ?? 32;
const args = ['log', `-n${maxEntries}`, `--format=${COMMIT_FORMAT}`, '-z', '--', uri.fsPath];
const args = ['log', `--format=${COMMIT_FORMAT}`, '-z'];
if (options?.maxEntries && !options?.reverse) {
args.push(`-n${options.maxEntries}`);
}
if (options?.hash) {
// If we are reversing, we must add a range (with HEAD) because we are using --ancestry-path for better reverse walking
if (options?.reverse) {
args.push('--reverse', '--ancestry-path', `${options.hash}..HEAD`);
} else {
args.push(options.hash);
}
}
if (options?.sortByAuthorDate) {
args.push('--author-date-order');
}
args.push('--', uri.fsPath);
const result = await this.run(args);
if (result.exitCode) {

View file

@ -5,7 +5,7 @@
import * as dayjs from 'dayjs';
import * as advancedFormat from 'dayjs/plugin/advancedFormat';
import { CancellationToken, Disposable, Event, EventEmitter, ThemeIcon, Timeline, TimelineChangeEvent, TimelineCursor, TimelineItem, TimelineProvider, Uri, workspace } from 'vscode';
import { CancellationToken, Disposable, Event, EventEmitter, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace } from 'vscode';
import { Model } from './model';
import { Repository } from './repository';
import { debounce } from './decorators';
@ -87,7 +87,7 @@ export class GitTimelineProvider implements TimelineProvider {
this._disposable.dispose();
}
async provideTimeline(uri: Uri, _cursor: TimelineCursor, _token: CancellationToken): Promise<Timeline> {
async provideTimeline(uri: Uri, options: TimelineOptions, _token: CancellationToken): Promise<Timeline> {
// console.log(`GitTimelineProvider.provideTimeline: uri=${uri} state=${this._model.state}`);
const repo = this._model.getRepository(uri);
@ -112,109 +112,152 @@ export class GitTimelineProvider implements TimelineProvider {
// TODO[ECA]: Ensure that the uri is a file -- if not we could get the history of the repo?
const commits = await repo.logFile(uri);
let limit: number | undefined;
if (typeof options.limit === 'string') {
try {
const result = await this._model.git.exec(repo.root, ['rev-list', '--count', `${options.limit}..`, '--', uri.fsPath]);
if (!result.exitCode) {
// Ask for 1 more than so we can determine if there are more commits
limit = Number(result.stdout) + 1;
}
}
catch {
limit = undefined;
}
} else {
// If we are not getting everything, ask for 1 more than so we can determine if there are more commits
limit = options.limit === undefined ? undefined : options.limit + 1;
}
const commits = await repo.logFile(uri, {
maxEntries: limit,
hash: options.cursor,
reverse: options.before,
// sortByAuthorDate: true
});
const more = limit === undefined || options.before ? false : commits.length >= limit;
const paging = commits.length ? {
more: more,
cursors: {
before: commits[0]?.hash,
after: commits[commits.length - (more ? 1 : 2)]?.hash
}
} : undefined;
// If we asked for an extra commit, strip it off
if (limit !== undefined && commits.length >= limit) {
commits.splice(commits.length - 1, 1);
}
let dateFormatter: dayjs.Dayjs;
const items = commits.map<GitTimelineItem>(c => {
dateFormatter = dayjs(c.authorDate);
const date = c.commitDate; // c.authorDate
const item = new GitTimelineItem(c.hash, `${c.hash}^`, c.message, c.authorDate?.getTime() ?? 0, c.hash, 'git:file:commit');
dateFormatter = dayjs(date);
const item = new GitTimelineItem(c.hash, `${c.hash}^`, c.message, date?.getTime() ?? 0, c.hash, 'git:file:commit');
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = c.authorName;
item.detail = `${c.authorName} (${c.authorEmail}) \u2014 ${c.hash.substr(0, 8)}\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n\n${c.message}`;
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
arguments: [uri, this.id, item]
arguments: [item, uri, this.id]
};
return item;
});
const index = repo.indexGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
if (index) {
const date = this._repoStatusDate ?? new Date();
dateFormatter = dayjs(date);
if (options.cursor === undefined || options.before) {
const index = repo.indexGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
if (index) {
const date = this._repoStatusDate ?? new Date();
dateFormatter = dayjs(date);
let status;
switch (index.type) {
case Status.INDEX_MODIFIED:
status = 'Modified';
break;
case Status.INDEX_ADDED:
status = 'Added';
break;
case Status.INDEX_DELETED:
status = 'Deleted';
break;
case Status.INDEX_RENAMED:
status = 'Renamed';
break;
case Status.INDEX_COPIED:
status = 'Copied';
break;
default:
status = '';
break;
let status;
switch (index.type) {
case Status.INDEX_MODIFIED:
status = 'Modified';
break;
case Status.INDEX_ADDED:
status = 'Added';
break;
case Status.INDEX_DELETED:
status = 'Deleted';
break;
case Status.INDEX_RENAMED:
status = 'Renamed';
break;
case Status.INDEX_COPIED:
status = 'Copied';
break;
default:
status = '';
break;
}
const item = new GitTimelineItem('~', 'HEAD', 'Staged Changes', date.getTime(), 'index', 'git:file:index');
// TODO[ECA]: Replace with a better icon -- reflecting its status maybe?
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = 'You';
item.detail = `You \u2014 Index\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n${status}`;
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
arguments: [item, uri, this.id]
};
items.splice(0, 0, item);
}
const item = new GitTimelineItem('~', 'HEAD', 'Staged Changes', date.getTime(), 'index', 'git:file:index');
// TODO[ECA]: Replace with a better icon -- reflecting its status maybe?
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = 'You';
item.detail = `You \u2014 Index\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n${status}`;
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
arguments: [uri, this.id, item]
};
const working = repo.workingTreeGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
if (working) {
const date = new Date();
dateFormatter = dayjs(date);
items.push(item);
}
let status;
switch (working.type) {
case Status.INDEX_MODIFIED:
status = 'Modified';
break;
case Status.INDEX_ADDED:
status = 'Added';
break;
case Status.INDEX_DELETED:
status = 'Deleted';
break;
case Status.INDEX_RENAMED:
status = 'Renamed';
break;
case Status.INDEX_COPIED:
status = 'Copied';
break;
default:
status = '';
break;
}
const item = new GitTimelineItem('', index ? '~' : 'HEAD', 'Uncommited Changes', date.getTime(), 'working', 'git:file:working');
// TODO[ECA]: Replace with a better icon -- reflecting its status maybe?
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = 'You';
item.detail = `You \u2014 Working Tree\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n${status}`;
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
arguments: [item, uri, this.id]
};
const working = repo.workingTreeGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
if (working) {
const date = new Date();
dateFormatter = dayjs(date);
let status;
switch (working.type) {
case Status.INDEX_MODIFIED:
status = 'Modified';
break;
case Status.INDEX_ADDED:
status = 'Added';
break;
case Status.INDEX_DELETED:
status = 'Deleted';
break;
case Status.INDEX_RENAMED:
status = 'Renamed';
break;
case Status.INDEX_COPIED:
status = 'Copied';
break;
default:
status = '';
break;
items.splice(0, 0, item);
}
const item = new GitTimelineItem('', index ? '~' : 'HEAD', 'Uncommited Changes', date.getTime(), 'working', 'git:file:working');
// TODO[ECA]: Replace with a better icon -- reflecting its status maybe?
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = 'You';
item.detail = `You \u2014 Working Tree\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n${status}`;
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
arguments: [uri, this.id, item]
};
items.push(item);
}
return { items: items };
return {
items: items,
paging: paging
};
}
private onRepositoriesChanged(_repo: Repository) {
@ -241,6 +284,6 @@ export class GitTimelineProvider implements TimelineProvider {
@debounce(500)
private fireChanged() {
this._onDidChange.fire({});
this._onDidChange.fire({ reset: true });
}
}

View file

@ -125,6 +125,19 @@ export module Iterator {
};
}
export function some<T>(iterator: Iterator<T> | NativeIterator<T>, fn: (t: T) => boolean): boolean {
while (true) {
const element = iterator.next();
if (element.done) {
return false;
}
if (fn(element.value)) {
return true;
}
}
}
export function forEach<T>(iterator: Iterator<T>, fn: (t: T) => void): void {
for (let next = iterator.next(); !next.done; next = iterator.next()) {
fn(next.value);

View file

@ -1558,12 +1558,9 @@ declare module 'vscode' {
label: string;
/**
* Optional id for the timeline item.
*/
/**
* Optional id for the timeline item that has to be unique across your timeline source.
* Optional id for the timeline item. It must be unique across all the timeline items provided by this source.
*
* If not provided, an id is generated using the timeline item's label.
* If not provided, an id is generated using the timeline item's timestamp.
*/
id?: string;
@ -1620,40 +1617,50 @@ declare module 'vscode' {
* If the [uri](#Uri) is `undefined` that signals that the timeline source for all resources changed.
*/
uri?: Uri;
}
export interface TimelineCursor {
/**
* A provider-defined cursor specifing the range of timeline items to be returned. Must be serializable.
*/
cursor?: any;
/**
* A flag to specify whether the timeline items requested are before or after (default) the provided cursor.
* A flag which indicates whether the entire timeline should be reset.
*/
before?: boolean;
/**
* The maximum number of timeline items that should be returned.
*/
limit?: number;
reset?: boolean;
}
export interface Timeline {
/**
* A provider-defined cursor specifing the range of timeline items returned. Must be serializable.
*/
cursor?: any;
readonly paging?: {
/**
* A set of provider-defined cursors specifing the range of timeline items returned.
*/
readonly cursors: {
readonly before: string;
readonly after?: string
};
/**
* A flag which indicates whether there are any more items that weren't returned.
*/
more?: boolean;
/**
* A flag which indicates whether there are more items that weren't returned.
*/
readonly more?: boolean;
}
/**
* An array of [timeline items](#TimelineItem).
*/
items: TimelineItem[];
readonly items: readonly TimelineItem[];
}
export interface TimelineOptions {
/**
* A provider-defined cursor specifing the range of timeline items that should be returned.
*/
cursor?: string;
/**
* A flag to specify whether the timeline items being requested should be before or after (default) the provided cursor.
*/
before?: boolean;
/**
* The maximum number or the ending cursor of timeline items that should be returned.
*/
limit?: number | string;
}
export interface TimelineProvider {
@ -1666,23 +1673,23 @@ declare module 'vscode' {
/**
* An identifier of the source of the timeline items. This can be used to filter sources.
*/
id: string;
readonly id: string;
/**
* A human-readable string describing the source of the timeline items. This can be used as the display label when filtering sources.
*/
label: string;
readonly label: string;
/**
* Provide [timeline items](#TimelineItem) for a [Uri](#Uri).
*
* @param uri The [uri](#Uri) of the file to provide the timeline for.
* @param options A set of options to determine how results should be returned.
* @param token A cancellation token.
* @param cursor TBD
* @return The [timeline result](#TimelineResult) or a thenable that resolves to such. The lack of a result
* can be signaled by returning `undefined`, `null`, or an empty array.
*/
provideTimeline(uri: Uri, cursor: TimelineCursor, token: CancellationToken): ProviderResult<Timeline>;
provideTimeline(uri: Uri, options: TimelineOptions, token: CancellationToken): ProviderResult<Timeline>;
}
export namespace workspace {

View file

@ -9,7 +9,7 @@ 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 { TimelineChangeEvent, TimelineCursor, TimelineProviderDescriptor, ITimelineService } from 'vs/workbench/contrib/timeline/common/timeline';
import { TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor, ITimelineService } from 'vs/workbench/contrib/timeline/common/timeline';
@extHostNamedCustomer(MainContext.MainThreadTimeline)
export class MainThreadTimeline implements MainThreadTimelineShape {
@ -39,8 +39,8 @@ export class MainThreadTimeline implements MainThreadTimelineShape {
this._timelineService.registerTimelineProvider({
...provider,
onDidChange: onDidChange.event,
provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }) {
return proxy.$getTimeline(provider.id, uri, cursor, token, options);
provideTimeline(uri: URI, options: TimelineOptions, token: CancellationToken, internalOptions?: { cacheResults?: boolean }) {
return proxy.$getTimeline(provider.id, uri, options, token, internalOptions);
},
dispose() {
emitters.delete(provider.id);

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 { Timeline, TimelineChangeEvent, TimelineCursor, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline';
import { Timeline, TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline';
import { revive } from 'vs/base/common/marshalling';
import { CallHierarchyItem } from 'vs/workbench/contrib/callHierarchy/common/callHierarchy';
import { Dto } from 'vs/base/common/types';
@ -1468,7 +1468,7 @@ export interface ExtHostTunnelServiceShape {
}
export interface ExtHostTimelineShape {
$getTimeline(source: string, uri: UriComponents, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
$getTimeline(source: string, uri: UriComponents, options: TimelineOptions, token: CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
}
// --- proxy identifiers

View file

@ -7,7 +7,7 @@ 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 { Timeline, TimelineCursor, TimelineItem, TimelineProvider } from 'vs/workbench/contrib/timeline/common/timeline';
import { Timeline, TimelineItem, TimelineOptions, 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, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands';
@ -16,7 +16,7 @@ import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
export interface IExtHostTimeline extends ExtHostTimelineShape {
readonly _serviceBrand: undefined;
$getTimeline(id: string, uri: UriComponents, cursor: vscode.TimelineCursor, token: vscode.CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
$getTimeline(id: string, uri: UriComponents, options: vscode.TimelineOptions, token: vscode.CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
}
export const IExtHostTimeline = createDecorator<IExtHostTimeline>('IExtHostTimeline');
@ -50,9 +50,9 @@ export class ExtHostTimeline implements IExtHostTimeline {
});
}
async $getTimeline(id: string, uri: UriComponents, cursor: vscode.TimelineCursor, token: vscode.CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined> {
async $getTimeline(id: string, uri: UriComponents, options: vscode.TimelineOptions, token: vscode.CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise<Timeline | undefined> {
const provider = this._providers.get(id);
return provider?.provideTimeline(URI.revive(uri), cursor, token, options);
return provider?.provideTimeline(URI.revive(uri), options, token, internalOptions);
}
registerTimelineProvider(scheme: string | string[], provider: vscode.TimelineProvider, _extensionId: ExtensionIdentifier, commandConverter: CommandsConverter): IDisposable {
@ -70,15 +70,15 @@ export class ExtHostTimeline implements IExtHostTimeline {
...provider,
scheme: scheme,
onDidChange: undefined,
async provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }) {
async provideTimeline(uri: URI, options: TimelineOptions, token: CancellationToken, internalOptions?: { cacheResults?: boolean }) {
timelineDisposables.clear();
// For now, only allow the caching of a single Uri
if (options?.cacheResults && !itemsBySourceByUriMap.has(getUriKey(uri))) {
if (internalOptions?.cacheResults && !itemsBySourceByUriMap.has(getUriKey(uri))) {
itemsBySourceByUriMap.clear();
}
const result = await provider.provideTimeline(uri, cursor, token);
const result = await provider.provideTimeline(uri, options, token);
// Intentional == we don't know how a provider will respond
// eslint-disable-next-line eqeqeq
if (result == null) {
@ -86,7 +86,7 @@ export class ExtHostTimeline implements IExtHostTimeline {
}
// TODO: Determine if we should cache dependent on who calls us (internal vs external)
const convertItem = convertTimelineItem(uri, options?.cacheResults ?? false);
const convertItem = convertTimelineItem(uri, internalOptions?.cacheResults ?? false);
return {
...result,
source: provider.id,
@ -143,6 +143,7 @@ export class ExtHostTimeline implements IExtHostTimeline {
return {
...props,
id: props.id ?? undefined,
handle: handle,
source: source,
command: item.command ? commandConverter.toInternal(item.command, disposables) : undefined,

View file

@ -14,6 +14,8 @@ import { TimelineService } from 'vs/workbench/contrib/timeline/common/timelineSe
import { TimelinePane } from './timelinePane';
import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from 'vs/platform/configuration/common/configurationRegistry';
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
import { MenuId, MenuRegistry } from 'vs/platform/actions/common/actions';
import { ICommandHandler, CommandsRegistry } from 'vs/platform/commands/common/commands';
import product from 'vs/platform/product/common/product';
export class TimelinePaneDescriptor implements IViewDescriptor {
@ -51,4 +53,91 @@ if (product.quality !== 'stable') {
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([new TimelinePaneDescriptor()], VIEW_CONTAINER);
}
namespace TimelineViewRefreshAction {
export const ID = 'timeline.refresh';
export const LABEL = localize('timeline.refreshView', "Refresh");
export function handler(): ICommandHandler {
return (accessor, arg) => {
const service = accessor.get(ITimelineService);
return service.reset();
};
}
}
CommandsRegistry.registerCommand(TimelineViewRefreshAction.ID, TimelineViewRefreshAction.handler());
// namespace TimelineViewRefreshHardAction {
// export const ID = 'timeline.refreshHard';
// export const LABEL = localize('timeline.refreshHard', "Refresh (Hard)");
// export function handler(fetch?: 'all' | 'more'): ICommandHandler {
// return (accessor, arg) => {
// const service = accessor.get(ITimelineService);
// return service.refresh(fetch);
// };
// }
// }
// CommandsRegistry.registerCommand(TimelineViewRefreshAction.ID, TimelineViewRefreshAction.handler());
// namespace TimelineViewLoadMoreAction {
// export const ID = 'timeline.loadMore';
// export const LABEL = localize('timeline.loadMoreInView', "Load More");
// export function handler(): ICommandHandler {
// return (accessor, arg) => {
// const service = accessor.get(ITimelineService);
// return service.refresh('more');
// };
// }
// }
// CommandsRegistry.registerCommand(TimelineViewLoadMoreAction.ID, TimelineViewLoadMoreAction.handler());
// namespace TimelineViewLoadAllAction {
// export const ID = 'timeline.loadAll';
// export const LABEL = localize('timeline.loadAllInView', "Load All");
// export function handler(): ICommandHandler {
// return (accessor, arg) => {
// const service = accessor.get(ITimelineService);
// return service.refresh('all');
// };
// }
// }
// CommandsRegistry.registerCommand(TimelineViewLoadAllAction.ID, TimelineViewLoadAllAction.handler());
MenuRegistry.appendMenuItem(MenuId.TimelineTitle, ({
group: 'navigation',
order: 1,
command: {
id: TimelineViewRefreshAction.ID,
title: TimelineViewRefreshAction.LABEL,
icon: { id: 'codicon/refresh' }
}
}));
// MenuRegistry.appendMenuItem(MenuId.TimelineTitle, ({
// group: 'navigation',
// order: 2,
// command: {
// id: TimelineViewLoadMoreAction.ID,
// title: TimelineViewLoadMoreAction.LABEL,
// icon: { id: 'codicon/unfold' }
// },
// alt: {
// id: TimelineViewLoadAllAction.ID,
// title: TimelineViewLoadAllAction.LABEL,
// icon: { id: 'codicon/unfold' }
// }
// }));
registerSingleton(ITimelineService, TimelineService, true);

View file

@ -8,6 +8,7 @@ import { localize } from 'vs/nls';
import * as DOM from 'vs/base/browser/dom';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { FuzzyScore, createMatches } from 'vs/base/common/filters';
import { Iterator } from 'vs/base/common/iterator';
import { DisposableStore, IDisposable, Disposable } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel';
@ -20,7 +21,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 { ITimelineService, TimelineChangeEvent, TimelineProvidersChangeEvent, TimelineRequest, TimelineItem } from 'vs/workbench/contrib/timeline/common/timeline';
import { ITimelineService, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvidersChangeEvent, TimelineRequest, Timeline } 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';
@ -28,7 +29,6 @@ import { IThemeService, LIGHT, ThemeIcon } from 'vs/platform/theme/common/themeS
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';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IActionViewItemProvider, ActionBar, ActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar';
@ -40,13 +40,53 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
// TODO[ECA]: Localize all the strings
type TreeElement = TimelineItem;
const InitialPageSize = 20;
const SubsequentPageSize = 40;
interface CommandItem {
handle: 'vscode-command:loadMore';
timestamp: number;
label: string;
themeIcon?: { id: string };
description?: string;
detail?: string;
contextValue?: string;
// Make things easier for duck typing
id: undefined;
icon: undefined;
iconDark: undefined;
source: undefined;
}
type TreeElement = TimelineItem | CommandItem;
// function isCommandItem(item: TreeElement | undefined): item is CommandItem {
// return item?.handle.startsWith('vscode-command:') ?? false;
// }
function isLoadMoreCommandItem(item: TreeElement | undefined): item is CommandItem & {
handle: 'vscode-command:loadMore';
} {
return item?.handle === 'vscode-command:loadMore';
}
function isTimelineItem(item: TreeElement | undefined): item is TimelineItem {
return !item?.handle.startsWith('vscode-command:') ?? false;
}
interface TimelineActionContext {
uri: URI | undefined;
item: TreeElement;
}
interface TimelineCursors {
startCursors?: { before: any; after?: any };
endCursors?: { before: any; after?: any };
more: boolean;
}
export class TimelinePane extends ViewPane {
static readonly ID = 'timeline';
static readonly TITLE = localize('timeline', 'Timeline');
@ -60,7 +100,8 @@ export class TimelinePane extends ViewPane {
private _visibilityDisposables: DisposableStore | undefined;
// private _excludedSources: Set<string> | undefined;
private _items: TimelineItem[] = [];
private _cursorsByProvider: Map<string, TimelineCursors> = new Map();
private _items: { element: TreeElement }[] = [];
private _loadingMessageTimer: any | undefined;
private _pendingRequests = new Map<string, TimelineRequest>();
private _uri: URI | undefined;
@ -105,7 +146,7 @@ export class TimelinePane extends ViewPane {
this._uri = uri;
this._treeRenderer?.setUri(uri);
this.loadTimeline();
this.loadTimeline(true);
}
private onProvidersChanged(e: TimelineProvidersChangeEvent) {
@ -116,16 +157,20 @@ export class TimelinePane extends ViewPane {
}
if (e.added) {
this.loadTimeline(e.added);
this.loadTimeline(true, e.added);
}
}
private onTimelineChanged(e: TimelineChangeEvent) {
if (e.uri === undefined || e.uri.toString(true) !== this._uri?.toString(true)) {
this.loadTimeline([e.id]);
if (e?.uri === undefined || e.uri.toString(true) !== this._uri?.toString(true)) {
this.loadTimeline(e.reset ?? false, e?.id === undefined ? undefined : [e.id], { before: !e.reset });
}
}
private onReset() {
this.loadTimeline(true);
}
private _message: string | undefined;
get message(): string | undefined {
return this._message;
@ -160,22 +205,27 @@ export class TimelinePane extends ViewPane {
DOM.clearNode(this._messageElement);
}
private async loadTimeline(sources?: string[]) {
private async loadTimeline(reset: boolean, sources?: string[], options: TimelineOptions = {}) {
const defaultPageSize = reset ? InitialPageSize : SubsequentPageSize;
// 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;
if (reset) {
this._items.length = 0;
this._cursorsByProvider.clear();
if (this._loadingMessageTimer) {
clearTimeout(this._loadingMessageTimer);
this._loadingMessageTimer = undefined;
if (this._loadingMessageTimer) {
clearTimeout(this._loadingMessageTimer);
this._loadingMessageTimer = undefined;
}
for (const { tokenSource } of this._pendingRequests.values()) {
tokenSource.dispose(true);
}
this._pendingRequests.clear();
}
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.';
@ -184,7 +234,7 @@ export class TimelinePane extends ViewPane {
return;
}
if (this._uri !== undefined) {
if (reset && this._uri !== undefined) {
this._loadingMessageTimer = setTimeout((uri: URI) => {
if (uri !== this._uri) {
return;
@ -200,50 +250,155 @@ export class TimelinePane extends ViewPane {
return;
}
let lastIndex = this._items.length - 1;
let lastItem = this._items[lastIndex]?.element;
if (isLoadMoreCommandItem(lastItem)) {
lastItem.themeIcon = { id: 'sync~spin' };
// this._items.splice(lastIndex, 1);
lastIndex--;
if (!reset && !options.before) {
lastItem = this._items[lastIndex]?.element;
const selection = [lastItem];
this._tree.setSelection(selection);
this._tree.setFocus(selection);
}
}
for (const source of sources ?? this.timelineService.getSources()) {
let request = this._pendingRequests.get(source);
request?.tokenSource.dispose(true);
request = this.timelineService.getTimeline(source, this._uri, {}, new CancellationTokenSource(), { cacheResults: true })!;
const cursors = this._cursorsByProvider.get(source);
if (!reset) {
// TODO: Handle pending request
this._pendingRequests.set(source, request);
request.tokenSource.token.onCancellationRequested(() => this._pendingRequests.delete(source));
if (cursors?.more === false) {
continue;
}
const reusingToken = request?.tokenSource !== undefined;
request = this.timelineService.getTimeline(
source, this._uri,
{
cursor: options.before ? cursors?.startCursors?.before : (cursors?.endCursors ?? cursors?.startCursors)?.after,
...options,
limit: options.limit === 0 ? undefined : options.limit ?? defaultPageSize
},
request?.tokenSource ?? new CancellationTokenSource(), { cacheResults: true }
)!;
this._pendingRequests.set(source, request);
if (!reusingToken) {
request.tokenSource.token.onCancellationRequested(() => this._pendingRequests.delete(source));
}
} else {
request?.tokenSource.dispose(true);
request = this.timelineService.getTimeline(
source, this._uri,
{
...options,
limit: options.limit === 0 ? undefined : (reset ? cursors?.endCursors?.after : undefined) ?? options.limit ?? defaultPageSize
},
new CancellationTokenSource(), { cacheResults: true }
)!;
this._pendingRequests.set(source, request);
request.tokenSource.token.onCancellationRequested(() => this._pendingRequests.delete(source));
}
this.handleRequest(request);
}
}
private async handleRequest(request: TimelineRequest) {
let items;
let timeline: Timeline | undefined;
try {
items = await this.progressService.withProgress({ location: VIEWLET_ID }, () => request.result.then(r => r?.items ?? []));
timeline = await this.progressService.withProgress({ location: this.getProgressLocation() }, () => request.result);
}
finally {
this._pendingRequests.delete(request.source);
}
catch { }
this._pendingRequests.delete(request.source);
if (request.tokenSource.token.isCancellationRequested || request.uri !== this._uri) {
if (
timeline === undefined ||
request.tokenSource.token.isCancellationRequested ||
request.uri !== this._uri
) {
return;
}
this.replaceItems(request.source, items);
}
let items: TreeElement[];
private replaceItems(source: string, items?: TimelineItem[]) {
const hasItems = this._items.length !== 0;
const source = request.source;
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' }));
if (timeline !== undefined) {
if (timeline.paging !== undefined) {
let cursors = this._cursorsByProvider.get(timeline.source ?? source);
if (cursors === undefined) {
cursors = { startCursors: timeline.paging.cursors, more: timeline.paging.more ?? false };
this._cursorsByProvider.set(timeline.source, cursors);
} else {
if (request.options.before) {
if (cursors.endCursors === undefined) {
cursors.endCursors = cursors.startCursors;
}
cursors.startCursors = timeline.paging.cursors;
}
else {
if (cursors.startCursors === undefined) {
cursors.startCursors = timeline.paging.cursors;
}
cursors.endCursors = timeline.paging.cursors;
}
cursors.more = timeline.paging.more ?? true;
}
}
} else {
this._cursorsByProvider.delete(source);
}
else if (this._items.length && this._items.some(i => i.source === source)) {
this._items = this._items.filter(i => i.source !== source);
items = (timeline.items as TreeElement[]) ?? [];
const alreadyHadItems = this._items.length !== 0;
let changed;
if (request.options.cursor) {
changed = this.mergeItems(request.source, items, request.options);
} else {
changed = this.replaceItems(request.source, items);
}
else {
if (!changed) {
return;
}
if (this._pendingRequests.size === 0 && this._items.length !== 0) {
const lastIndex = this._items.length - 1;
const lastItem = this._items[lastIndex]?.element;
if (timeline.paging?.more || Iterator.some(this._cursorsByProvider.values(), cursors => cursors.more)) {
if (isLoadMoreCommandItem(lastItem)) {
lastItem.themeIcon = undefined;
}
else {
this._items.push({
element: {
handle: 'vscode-command:loadMore',
label: 'Load more',
timestamp: 0
} as CommandItem
});
}
}
else {
if (isLoadMoreCommandItem(lastItem)) {
this._items.splice(lastIndex, 1);
}
}
}
// 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) {
if (alreadyHadItems && this._pendingRequests.size !== 0) {
this.refreshDebounced();
}
else {
@ -251,6 +406,79 @@ export class TimelinePane extends ViewPane {
}
}
private mergeItems(source: string, items: TreeElement[] | undefined, options: TimelineOptions): boolean {
if (items?.length === undefined || items.length === 0) {
return false;
}
if (options.before) {
const ids = new Set();
const timestamps = new Set();
for (const item of items) {
if (item.id === undefined) {
timestamps.add(item.timestamp);
}
else {
ids.add(item.id);
}
}
// Remove any duplicate items
// I don't think we need to check all the items, just the most recent page
let i = Math.min(SubsequentPageSize, this._items.length);
let item;
while (i--) {
item = this._items[i].element;
if (
(item.id === undefined && ids.has(item.id)) ||
(item.timestamp === undefined && timestamps.has(item.timestamp))
) {
this._items.splice(i, 1);
}
}
this._items.splice(0, 0, ...items.map(item => ({ element: item })));
} else {
this._items.push(...items.map(item => ({ element: item })));
}
this.sortItems();
return true;
}
private replaceItems(source: string, items?: TreeElement[]): boolean {
if (items?.length) {
this._items.splice(
0, this._items.length,
...this._items.filter(item => item.element.source !== source),
...items.map(item => ({ element: item }))
);
this.sortItems();
return true;
}
if (this._items.length && this._items.some(item => item.element.source === source)) {
this._items = this._items.filter(item => item.element.source !== source);
return true;
}
return false;
}
private sortItems() {
this._items.sort(
(a, b) =>
(b.element.timestamp - a.element.timestamp) ||
(a.element.source === undefined
? b.element.source === undefined ? 0 : 1
: b.element.source === undefined ? -1 : b.element.source.localeCompare(a.element.source, undefined, { numeric: true, sensitivity: 'base' }))
);
}
private refresh() {
if (this._loadingMessageTimer) {
clearTimeout(this._loadingMessageTimer);
@ -263,7 +491,7 @@ export class TimelinePane extends ViewPane {
this.message = undefined;
}
this._tree.setChildren(null, this._items.map(item => ({ element: item })));
this._tree.setChildren(null, this._items);
}
@debounce(500)
@ -282,6 +510,7 @@ export class TimelinePane extends ViewPane {
this.timelineService.onDidChangeProviders(this.onProvidersChanged, this, this._visibilityDisposables);
this.timelineService.onDidChangeTimeline(this.onTimelineChanged, this, this._visibilityDisposables);
this.timelineService.onDidReset(this.onReset, this, this._visibilityDisposables);
this.editorService.onDidActiveEditorChange(this.onActiveEditorChanged, this, this._visibilityDisposables);
this.onActiveEditorChanged();
@ -329,9 +558,24 @@ export class TimelinePane extends ViewPane {
}
const selection = this._tree.getSelection();
const command = selection.length === 1 ? selection[0]?.command : undefined;
if (command) {
this.commandService.executeCommand(command.id, ...(command.arguments || []));
const item = selection.length === 1 ? selection[0] : undefined;
// eslint-disable-next-line eqeqeq
if (item == null) {
return;
}
if (isTimelineItem(item)) {
if (item.command) {
this.commandService.executeCommand(item.command.id, ...(item.command.arguments || []));
}
}
else if (isLoadMoreCommandItem(item)) {
// TODO: Change this, but right now this is the pending signal
if (item.themeIcon !== undefined) {
return;
}
this.loadTimeline(false);
}
})
);
@ -417,6 +661,11 @@ export class TimelineIdentityProvider implements IIdentityProvider<TreeElement>
class TimelineActionRunner extends ActionRunner {
runAction(action: IAction, { uri, item }: TimelineActionContext): Promise<any> {
if (!isTimelineItem(item)) {
// TODO
return action.run();
}
return action.run(...[
{
$mid: 11,
@ -499,7 +748,7 @@ class TimelineTreeRenderer implements ITreeRenderer<TreeElement, FuzzyScore, Tim
matches: createMatches(node.filterData)
});
template.timestamp.textContent = fromNow(item.timestamp);
template.timestamp.textContent = isTimelineItem(item) ? fromNow(item.timestamp) : '';
template.actionBar.context = { uri: this._uri, item: item } as TimelineActionContext;
template.actionBar.actionRunner = new TimelineActionRunner();

View file

@ -19,6 +19,7 @@ export interface TimelineItem {
handle: string;
source: string;
id?: string;
timestamp: number;
label: string;
icon?: URI,
@ -31,28 +32,34 @@ export interface TimelineItem {
}
export interface TimelineChangeEvent {
id: string;
id?: string;
uri?: URI;
reset?: boolean
}
export interface TimelineCursor {
cursor?: any;
export interface TimelineOptions {
cursor?: string;
before?: boolean;
limit?: number;
limit?: number | string;
}
export interface Timeline {
source: string;
items: TimelineItem[];
cursor?: any;
more?: boolean;
paging?: {
cursors: {
before: string;
after?: string
};
more?: boolean;
}
}
export interface TimelineProvider extends TimelineProviderDescriptor, IDisposable {
onDidChange?: Event<TimelineChangeEvent>;
provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
provideTimeline(uri: URI, options: TimelineOptions, token: CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
}
export interface TimelineProviderDescriptor {
@ -68,6 +75,7 @@ export interface TimelineProvidersChangeEvent {
export interface TimelineRequest {
readonly result: Promise<Timeline | undefined>;
readonly options: TimelineOptions;
readonly source: string;
readonly tokenSource: CancellationTokenSource;
readonly uri: URI;
@ -78,13 +86,17 @@ export interface ITimelineService {
onDidChangeProviders: Event<TimelineProvidersChangeEvent>;
onDidChangeTimeline: Event<TimelineChangeEvent>;
onDidReset: Event<void>;
registerTimelineProvider(provider: TimelineProvider): IDisposable;
unregisterTimelineProvider(id: string): void;
getSources(): string[];
getTimeline(id: string, uri: URI, cursor: TimelineCursor, tokenSource: CancellationTokenSource, options?: { cacheResults?: boolean }): TimelineRequest | undefined;
getTimeline(id: string, uri: URI, options: TimelineOptions, tokenSource: CancellationTokenSource, internalOptions?: { cacheResults?: boolean }): TimelineRequest | undefined;
// refresh(fetch?: 'all' | 'more'): void;
reset(): void;
}
const TIMELINE_SERVICE_ID = 'timeline';

View file

@ -9,7 +9,7 @@ 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, TimelineChangeEvent, TimelineCursor, TimelineProvidersChangeEvent, TimelineProvider } from './timeline';
import { ITimelineService, TimelineChangeEvent, TimelineOptions, TimelineProvidersChangeEvent, TimelineProvider } from './timeline';
export class TimelineService implements ITimelineService {
_serviceBrand: undefined;
@ -20,6 +20,9 @@ export class TimelineService implements ITimelineService {
private readonly _onDidChangeTimeline = new Emitter<TimelineChangeEvent>();
readonly onDidChangeTimeline: Event<TimelineChangeEvent> = this._onDidChangeTimeline.event;
private readonly _onDidReset = new Emitter<void>();
readonly onDidReset: Event<void> = this._onDidReset.event;
private readonly _providers = new Map<string, TimelineProvider>();
private readonly _providerSubscriptions = new Map<string, IDisposable>();
@ -81,7 +84,7 @@ export class TimelineService implements ITimelineService {
return [...this._providers.keys()];
}
getTimeline(id: string, uri: URI, cursor: TimelineCursor, tokenSource: CancellationTokenSource, options?: { cacheResults?: boolean }) {
getTimeline(id: string, uri: URI, options: TimelineOptions, tokenSource: CancellationTokenSource, internalOptions?: { cacheResults?: boolean }) {
this.logService.trace(`TimelineService#getTimeline(${id}): uri=${uri.toString(true)}`);
const provider = this._providers.get(id);
@ -98,7 +101,7 @@ export class TimelineService implements ITimelineService {
}
return {
result: provider.provideTimeline(uri, cursor, tokenSource.token, options)
result: provider.provideTimeline(uri, options, tokenSource.token, internalOptions)
.then(result => {
if (result === undefined) {
return undefined;
@ -109,6 +112,7 @@ export class TimelineService implements ITimelineService {
return result;
}),
options: options,
source: provider.id,
tokenSource: tokenSource,
uri: uri
@ -156,4 +160,12 @@ export class TimelineService implements ITimelineService {
this._providerSubscriptions.delete(id);
this._onDidChangeProviders.fire({ removed: [id] });
}
// refresh(fetch?: 'all' | 'more') {
// this._onDidChangeTimeline.fire({ fetch: fetch });
// }
reset() {
this._onDidReset.fire();
}
}