diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 89206132470..ac3120c115a 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -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 { - 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) { diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 9db3495aec7..f0a95d61a34 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -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 { + async provideTimeline(uri: Uri, options: TimelineOptions, _token: CancellationToken): Promise { // 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(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 }); } } diff --git a/src/vs/base/common/iterator.ts b/src/vs/base/common/iterator.ts index 9287a6eaebf..6c87c85cf48 100644 --- a/src/vs/base/common/iterator.ts +++ b/src/vs/base/common/iterator.ts @@ -125,6 +125,19 @@ export module Iterator { }; } + export function some(iterator: Iterator | NativeIterator, 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(iterator: Iterator, fn: (t: T) => void): void { for (let next = iterator.next(); !next.done; next = iterator.next()) { fn(next.value); diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index d04c46c8151..157c8732986 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -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; + provideTimeline(uri: Uri, options: TimelineOptions, token: CancellationToken): ProviderResult; } export namespace workspace { diff --git a/src/vs/workbench/api/browser/mainThreadTimeline.ts b/src/vs/workbench/api/browser/mainThreadTimeline.ts index 428bf0ed2d9..cfeb1c6c38c 100644 --- a/src/vs/workbench/api/browser/mainThreadTimeline.ts +++ b/src/vs/workbench/api/browser/mainThreadTimeline.ts @@ -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); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 9a28df76850..a54b3d0766a 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 { 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; + $getTimeline(source: string, uri: UriComponents, options: TimelineOptions, token: CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostTimeline.ts b/src/vs/workbench/api/common/extHostTimeline.ts index f5f63a2ce4d..87fefc82cfc 100644 --- a/src/vs/workbench/api/common/extHostTimeline.ts +++ b/src/vs/workbench/api/common/extHostTimeline.ts @@ -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; + $getTimeline(id: string, uri: UriComponents, options: vscode.TimelineOptions, token: vscode.CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise; } export const IExtHostTimeline = createDecorator('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 { + async $getTimeline(id: string, uri: UriComponents, options: vscode.TimelineOptions, token: vscode.CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise { 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, diff --git a/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts b/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts index 142fb5cb596..11cae1ddb07 100644 --- a/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts +++ b/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts @@ -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(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); diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index d15f9ab2a42..9e322c2fcd9 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -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 | undefined; - private _items: TimelineItem[] = []; + private _cursorsByProvider: Map = new Map(); + private _items: { element: TreeElement }[] = []; private _loadingMessageTimer: any | undefined; private _pendingRequests = new Map(); 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 class TimelineActionRunner extends ActionRunner { runAction(action: IAction, { uri, item }: TimelineActionContext): Promise { + if (!isTimelineItem(item)) { + // TODO + return action.run(); + } + return action.run(...[ { $mid: 11, @@ -499,7 +748,7 @@ class TimelineTreeRenderer implements ITreeRenderer; - provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }): Promise; + provideTimeline(uri: URI, options: TimelineOptions, token: CancellationToken, internalOptions?: { cacheResults?: boolean }): Promise; } export interface TimelineProviderDescriptor { @@ -68,6 +75,7 @@ export interface TimelineProvidersChangeEvent { export interface TimelineRequest { readonly result: Promise; + readonly options: TimelineOptions; readonly source: string; readonly tokenSource: CancellationTokenSource; readonly uri: URI; @@ -78,13 +86,17 @@ export interface ITimelineService { onDidChangeProviders: Event; onDidChangeTimeline: Event; + onDidReset: Event; 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'; diff --git a/src/vs/workbench/contrib/timeline/common/timelineService.ts b/src/vs/workbench/contrib/timeline/common/timelineService.ts index 27038106272..d017b15245d 100644 --- a/src/vs/workbench/contrib/timeline/common/timelineService.ts +++ b/src/vs/workbench/contrib/timeline/common/timelineService.ts @@ -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(); readonly onDidChangeTimeline: Event = this._onDidChangeTimeline.event; + private readonly _onDidReset = new Emitter(); + readonly onDidReset: Event = this._onDidReset.event; + private readonly _providers = new Map(); private readonly _providerSubscriptions = new Map(); @@ -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(); + } }