diff --git a/extensions/git/package.json b/extensions/git/package.json index 42025387d61..fff9c1739f6 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -447,6 +447,11 @@ "command": "git.stashDrop", "title": "%command.stashDrop%", "category": "Git" + }, + { + "command": "git.openDiff", + "title": "%command.openDiff%", + "category": "Git" } ], "menus": { diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index f3d75519653..7c9ad8ac5bf 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -70,6 +70,7 @@ "command.stashApply": "Apply Stash...", "command.stashApplyLatest": "Apply Latest Stash", "command.stashDrop": "Drop Stash...", + "command.openDiff": "Open Comparison", "config.enabled": "Whether git is enabled.", "config.path": "Path and filename of the git executable, e.g. `C:\\Program Files\\Git\\bin\\git.exe` (Windows).", "config.autoRepositoryDetection": "Configures when repositories should be automatically detected.", diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index f614d8eec99..68597304dbe 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -41,7 +41,9 @@ export interface Commit { readonly hash: string; readonly message: string; readonly parents: string[]; - readonly authorEmail?: string | undefined; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; } export interface Submodule { @@ -119,6 +121,14 @@ export interface LogOptions { readonly maxEntries?: number; } +/** + * Log file options. + */ +export interface LogFileOptions { + /** Max number of log entries to retrieve. If not specified, the default is 32. */ + readonly maxEntries?: number; +} + export interface Repository { readonly rootUri: Uri; diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 2414b16634f..173696967fd 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -2332,6 +2332,11 @@ export class CommandCenter { return result && result.stash; } + @command('git.openDiff', { repository: false }) + async openDiff(uri: Uri, hash: string) { + return commands.executeCommand('vscode.diff', toGitUri(uri, hash), toGitUri(uri, `${hash}^`)); + } + private createCommand(id: string, key: string, method: Function, options: CommandOptions): (...args: any[]) => any { const result = (...args: any[]) => { let result: Promise; diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index af92f83c1a6..fb5ca2f06b8 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -12,10 +12,10 @@ import { EventEmitter } from 'events'; import iconv = require('iconv-lite'); import * as filetype from 'file-type'; import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter } from './util'; -import { CancellationToken, Progress } from 'vscode'; +import { CancellationToken, Progress, Uri } from 'vscode'; import { URI } from 'vscode-uri'; import { detectEncoding } from './encoding'; -import { Ref, RefType, Branch, Remote, GitErrorCodes, LogOptions, Change, Status } from './api/git'; +import { Ref, RefType, Branch, Remote, GitErrorCodes, LogOptions, Change, Status, LogFileOptions } from './api/git'; import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -318,7 +318,7 @@ function getGitErrorCode(stderr: string): string | undefined { return undefined; } -const COMMIT_FORMAT = '%H\n%ae\n%P\n%B'; +const COMMIT_FORMAT = '%H\n%aN\n%aE\n%at\n%P\n%B'; export class Git { @@ -503,7 +503,9 @@ export interface Commit { hash: string; message: string; parents: string[]; - authorEmail?: string | undefined; + authorDate?: Date; + authorName?: string; + authorEmail?: string; } export class GitStatusParser { @@ -634,14 +636,43 @@ export function parseGitmodules(raw: string): Submodule[] { return result; } -export function parseGitCommit(raw: string): Commit | null { - const match = /^([0-9a-f]{40})\n(.*)\n(.*)(\n([^]*))?$/m.exec(raw.trim()); - if (!match) { - return null; - } +const commitRegex = /([0-9a-f]{40})\n(.*)\n(.*)\n(.*)\n(.*)(?:\n([^]*?))?(?:\x00)/gm; - const parents = match[3] ? match[3].split(' ') : []; - return { hash: match[1], message: match[5], parents, authorEmail: match[2] }; +export function parseGitCommits(data: string): Commit[] { + let commits: Commit[] = []; + + let ref; + let name; + let email; + let date; + let parents; + let message; + let match; + + do { + match = commitRegex.exec(data); + if (match === null) { + break; + } + + [, ref, name, email, date, parents, message] = match; + + if (message[message.length - 1] === '\n') { + message = message.substr(0, message.length - 1); + } + + // Stop excessive memory usage by using substr -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + commits.push({ + hash: ` ${ref}`.substr(1), + message: ` ${message}`.substr(1), + parents: parents ? parents.split(' ') : [], + authorDate: new Date(Number(date) * 1000), + authorName: ` ${name}`.substr(1), + authorEmail: ` ${email}`.substr(1) + }); + } while (true); + + return commits; } interface LsTreeElement { @@ -760,38 +791,28 @@ export class Repository { async log(options?: LogOptions): Promise { const maxEntries = options && typeof options.maxEntries === 'number' && options.maxEntries > 0 ? options.maxEntries : 32; - const args = ['log', '-' + maxEntries, `--pretty=format:${COMMIT_FORMAT}%x00%x00`]; + const args = ['log', '-' + maxEntries, `--format:${COMMIT_FORMAT}`, '-z']; - const gitResult = await this.run(args); - if (gitResult.exitCode) { + const result = await this.run(args); + if (result.exitCode) { // An empty repo return []; } - const s = gitResult.stdout; - const result: Commit[] = []; - let index = 0; - while (index < s.length) { - let nextIndex = s.indexOf('\x00\x00', index); - if (nextIndex === -1) { - nextIndex = s.length; - } + return parseGitCommits(result.stdout); + } - let entry = s.substr(index, nextIndex - index); - if (entry.startsWith('\n')) { - entry = entry.substring(1); - } + async logFile(uri: Uri, options?: LogFileOptions): Promise { + const maxEntries = options?.maxEntries ?? 32; + const args = ['log', `-${maxEntries}`, `--format=${COMMIT_FORMAT}`, '-z', '--', uri.fsPath]; - const commit = parseGitCommit(entry); - if (!commit) { - break; - } - - result.push(commit); - index = nextIndex + 2; + const result = await this.run(args); + if (result.exitCode) { + // No file history, e.g. a new file or untracked + return []; } - return result; + return parseGitCommits(result.stdout); } async bufferString(object: string, encoding: string = 'utf8', autoGuessEncoding = false): Promise { @@ -1853,8 +1874,12 @@ export class Repository { } async getCommit(ref: string): Promise { - const result = await this.run(['show', '-s', `--format=${COMMIT_FORMAT}`, ref]); - return parseGitCommit(result.stdout) || Promise.reject('bad commit format'); + const result = await this.run(['show', '-s', `--format=${COMMIT_FORMAT}`, '-z', ref]); + const commits = parseGitCommits(result.stdout); + if (commits.length === 0) { + return Promise.reject('bad commit format'); + } + return commits[0]; } async updateSubmodules(paths: string[]): Promise { diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index 80435de391a..7bb5081d12f 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -22,6 +22,7 @@ import { GitExtensionImpl } from './api/extension'; import * as path from 'path'; import * as fs from 'fs'; import { createIPCServer, IIPCServer } from './ipc/ipcServer'; +import { GitTimelineProvider } from './timelineProvider'; const deactivateTasks: { (): Promise; }[] = []; @@ -82,7 +83,8 @@ async function createModel(context: ExtensionContext, outputChannel: OutputChann new GitContentProvider(model), new GitFileSystemProvider(model), new GitDecorations(model), - new GitProtocolHandler() + new GitProtocolHandler(), + new GitTimelineProvider(model) ); await checkGitVersion(info); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index dd00637a502..11b6b7e575c 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CancellationToken, Command, Disposable, Event, EventEmitter, Memento, OutputChannel, ProgressLocation, ProgressOptions, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, ThemeColor, Uri, window, workspace, WorkspaceEdit, Decoration } from 'vscode'; import * as nls from 'vscode-nls'; -import { Branch, Change, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from './api/git'; +import { Branch, Change, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status, LogFileOptions } from './api/git'; import { AutoFetcher } from './autofetch'; import { debounce, memoize, throttle } from './decorators'; import { Commit, CommitOptions, ForcePushMode, GitError, Repository as BaseRepository, Stash, Submodule } from './git'; @@ -304,6 +304,7 @@ export const enum Operation { Apply = 'Apply', Blame = 'Blame', Log = 'Log', + LogFile = 'LogFile', } function isReadOnly(operation: Operation): boolean { @@ -868,6 +869,11 @@ export class Repository implements Disposable { return this.run(Operation.Log, () => this.repository.log(options)); } + logFile(uri: Uri, options?: LogFileOptions): Promise { + // TODO: This probably needs per-uri granularity + return this.run(Operation.LogFile, () => this.repository.logFile(uri, options)); + } + @throttle async status(): Promise { await this.run(Operation.Status); diff --git a/extensions/git/src/test/git.test.ts b/extensions/git/src/test/git.test.ts index a860c82352a..1d4c4ebe161 100644 --- a/extensions/git/src/test/git.test.ts +++ b/extensions/git/src/test/git.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'mocha'; -import { GitStatusParser, parseGitCommit, parseGitmodules, parseLsTree, parseLsFiles } from '../git'; +import { GitStatusParser, parseGitCommits, parseGitmodules, parseLsTree, parseLsFiles } from '../git'; import * as assert from 'assert'; import { splitInChunks } from '../util'; @@ -191,42 +191,42 @@ suite('git', () => { const GIT_OUTPUT_SINGLE_PARENT = `52c293a05038d865604c2284aa8698bd087915a1 john.doe@mail.com 8e5a374372b8393906c7e380dbb09349c5385554 -This is a commit message.`; +This is a commit message.\x00`; - assert.deepEqual(parseGitCommit(GIT_OUTPUT_SINGLE_PARENT), { + assert.deepEqual(parseGitCommits(GIT_OUTPUT_SINGLE_PARENT), [{ hash: '52c293a05038d865604c2284aa8698bd087915a1', message: 'This is a commit message.', parents: ['8e5a374372b8393906c7e380dbb09349c5385554'], authorEmail: 'john.doe@mail.com', - }); + }]); }); test('multiple parent commits', function () { const GIT_OUTPUT_MULTIPLE_PARENTS = `52c293a05038d865604c2284aa8698bd087915a1 john.doe@mail.com 8e5a374372b8393906c7e380dbb09349c5385554 df27d8c75b129ab9b178b386077da2822101b217 -This is a commit message.`; +This is a commit message.\x00`; - assert.deepEqual(parseGitCommit(GIT_OUTPUT_MULTIPLE_PARENTS), { + assert.deepEqual(parseGitCommits(GIT_OUTPUT_MULTIPLE_PARENTS), [{ hash: '52c293a05038d865604c2284aa8698bd087915a1', message: 'This is a commit message.', parents: ['8e5a374372b8393906c7e380dbb09349c5385554', 'df27d8c75b129ab9b178b386077da2822101b217'], authorEmail: 'john.doe@mail.com', - }); + }]); }); test('no parent commits', function () { const GIT_OUTPUT_NO_PARENTS = `52c293a05038d865604c2284aa8698bd087915a1 john.doe@mail.com -This is a commit message.`; +This is a commit message.\x00`; - assert.deepEqual(parseGitCommit(GIT_OUTPUT_NO_PARENTS), { + assert.deepEqual(parseGitCommits(GIT_OUTPUT_NO_PARENTS), [{ hash: '52c293a05038d865604c2284aa8698bd087915a1', message: 'This is a commit message.', parents: [], authorEmail: 'john.doe@mail.com', - }); + }]); }); }); diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts new file mode 100644 index 00000000000..2d0f40e4879 --- /dev/null +++ b/extensions/git/src/timelineProvider.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, Disposable, TimelineItem, TimelineProvider, Uri, workspace, ThemeIcon } from 'vscode'; +import { Model } from './model'; + +export class GitTimelineProvider implements TimelineProvider { + readonly source = 'git-history'; + readonly sourceDescription = 'Git History'; + + private _disposable: Disposable; + + constructor(private readonly _model: Model) { + this._disposable = workspace.registerTimelineProvider('*', this); + } + + dispose() { + this._disposable.dispose(); + } + + async provideTimeline(uri: Uri, _since: number, _token: CancellationToken): Promise { + const repo = this._model.getRepository(uri); + if (!repo) { + return []; + } + + const commits = await repo.logFile(uri, { maxEntries: 10 }); + return commits.map(c => { + let message = c.message; + + const index = message.indexOf('\n'); + if (index !== -1) { + message = `${message.substring(0, index)} \u2026`; + } + + return { + id: c.hash, + date: c.authorDate?.getTime() ?? 0, + iconPath: new ThemeIcon('git-commit'), + label: message, + description: `${c.authorName} (${c.authorEmail}) \u2022 ${c.hash.substr(0, 8)}`, + detail: `${c.authorName} (${c.authorEmail})\n${c.authorDate}\n\n${c.message}`, + command: { + title: 'Open Diff', + command: 'git.openDiff', + arguments: [uri, c.hash] + } + }; + }); + } +} diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 637c01bc656..cea83f1dfb8 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -960,8 +960,8 @@ declare module 'vscode' { export interface SourceControlInputBox { /** - * Controls whether the input box is visible (default is `true`). - */ + * Controls whether the input box is visible (default is `true`). + */ visible: boolean; } @@ -1315,8 +1315,8 @@ declare module 'vscode' { export interface QuickPick extends QuickInput { /** - * An optional flag to sort the final results by index of first query match in label. Defaults to true. - */ + * An optional flag to sort the final results by index of first query match in label. Defaults to true. + */ sortByLabel: boolean; } @@ -1478,12 +1478,6 @@ declare module 'vscode' { */ date: number; - /** - * A human-readable string describing the source of the timeline item. This can be used for filtering by sources so keep it consistent across timeline item types. - */ - source: string; - - /** * A human-readable string describing the timeline item. When `falsy`, it is derived from [resourceUri](#TreeItem.resourceUri). */ @@ -1497,22 +1491,17 @@ declare module 'vscode' { /** * The icon path or [ThemeIcon](#ThemeIcon) for the timeline item. See [TreeItem.iconPath](#TreeItem.iconPath) for more details. */ - iconPath?: string | Uri | { light: string | Uri; dark: string | Uri } | ThemeIcon; + 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. */ description?: string; - /** - * The [uri](#Uri) of the resource representing the timeline item (if any). See [TreeItem.resourceUri](#TreeItem.resourceUri) for more details. - */ - resourceUri?: Uri; - /** * The tooltip text when you hover over the timeline item. */ - tooltip?: string | undefined; + detail?: string; /** * The [command](#Command) that should be executed when the timeline item is selected. @@ -1532,36 +1521,47 @@ declare module 'vscode' { constructor(label: string, date: number, source: string); } - export interface TimelimeAddEvent { + // export interface TimelimeAddEvent { + // /** + // * An array of timeline items which have been added. + // */ + // readonly items: readonly TimelineItem[]; - /** - * An array of timeline items which have been added. - */ - readonly items: readonly TimelineItem[]; + // /** + // * The uri of the file to which the timeline items belong. + // */ + // readonly uri: Uri; + // } - /** - * The uri of the file to which the timeline items belong. - */ - readonly uri: Uri; - } + // export interface TimelimeChangeEvent { + // /** + // * The date after which the timeline has changed. If `undefined` the entire timeline will be reset. + // */ + // readonly since?: Date; - export interface TimelimeChangeEvent { - - /** - * The date after which the timeline has changed. If `undefined` the entire timeline will be reset. - */ - readonly since?: Date; - - /** - * The uri of the file to which the timeline changed. - */ - readonly uri: Uri; - } + // /** + // * The uri of the file to which the timeline changed. + // */ + // readonly uri: Uri; + // } export interface TimelineProvider { // onDidAdd?: Event; // onDidChange?: Event; - id: string; + + onDidChange?: Event; + + /** + * An identifier of the source of the timeline items. This can be used for filtering and/or overriding existing sources. + */ + source: string; + + /** + * A human-readable string describing the source of the timeline items. This can be as the display label when filtering by sources. + */ + sourceDescription: string; + + replaceable?: boolean; /** * Provide [timeline items](#TimelineItem) for a [Uri](#Uri) after a particular date. diff --git a/src/vs/workbench/api/browser/mainThreadTimeline.ts b/src/vs/workbench/api/browser/mainThreadTimeline.ts index c063dc1c1d1..8448adca0da 100644 --- a/src/vs/workbench/api/browser/mainThreadTimeline.ts +++ b/src/vs/workbench/api/browser/mainThreadTimeline.ts @@ -5,13 +5,12 @@ import { MainContext, MainThreadTimelineShape, IExtHostContext, ExtHostTimelineShape, ExtHostContext } from 'vs/workbench/api/common/extHost.protocol'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; -import { ITimelineService, TimelineItem } from 'vs/workbench/contrib/timeline/common/timeline'; +import { ITimelineService, TimelineItem, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline'; import { URI } from 'vs/base/common/uri'; import { CancellationToken } from 'vs/base/common/cancellation'; @extHostNamedCustomer(MainContext.MainThreadTimeline) export class MainThreadTimeline implements MainThreadTimelineShape { - private readonly _proxy: ExtHostTimelineShape; constructor( @@ -25,21 +24,23 @@ export class MainThreadTimeline implements MainThreadTimelineShape { return this._timelineService.getTimeline(uri, since, token); } - $registerTimelineProvider(key: string, id: string): void { - console.log(`MainThreadTimeline#registerTimelineProvider: key=${key}`); + $registerTimelineProvider(provider: TimelineProviderDescriptor): void { + console.log(`MainThreadTimeline#registerTimelineProvider: provider=${provider.source}`); const proxy = this._proxy; - this._timelineService.registerTimelineProvider(key, { - id: id, + + this._timelineService.registerTimelineProvider({ + ...provider, provideTimeline(uri: URI, since: number, token: CancellationToken) { - return proxy.$getTimeline(key, uri, since, token); - } + return proxy.$getTimeline(provider.source, uri, since, token); + }, + dispose() { } }); } - $unregisterTimelineProvider(key: string): void { - console.log(`MainThreadTimeline#unregisterTimelineProvider: key=${key}`); - this._timelineService.unregisterTimelineProvider(key); + $unregisterTimelineProvider(source: string): void { + console.log(`MainThreadTimeline#unregisterTimelineProvider: source=${source}`); + this._timelineService.unregisterTimelineProvider(source); } dispose(): void { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 397076d2d72..91045fcda6c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from 'vs/nls'; -import { CancellationTokenSource, CancellationToken } from 'vs/base/common/cancellation'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; import * as errors from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import * as path from 'vs/base/common/path'; @@ -133,7 +133,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); const extHostAuthentication = rpcProtocol.set(ExtHostContext.ExtHostAuthentication, new ExtHostAuthentication(rpcProtocol)); - const extHostTimelineService = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol)); + const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol)); // Check that no named customers are missing const expected: ProxyIdentifier[] = values(ExtHostContext); @@ -767,13 +767,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, registerTimelineProvider: (scheme: string, provider: vscode.TimelineProvider) => { checkProposedApiEnabled(extension); - return extHostTimelineService.registerTimelineProvider(extension.identifier, { - id: provider.id, - async provideTimeline(uri: URI, since: number, token: CancellationToken) { - const results = await provider.provideTimeline(uri, since, token); - return results ?? []; - } - }); + return extHostTimeline.registerTimelineProvider(provider, extHostCommands.converter); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index fcc3d247dbd..a77eede3689 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -49,7 +49,7 @@ import { SaveReason } from 'vs/workbench/common/editor'; import { ExtensionActivationReason } from 'vs/workbench/api/common/extHostExtensionActivator'; import { TunnelDto } from 'vs/workbench/api/common/extHostTunnelService'; import { TunnelOptions } from 'vs/platform/remote/common/tunnel'; -import { TimelineItem } from 'vs/workbench/contrib/timeline/common/timeline'; +import { TimelineItem, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline'; export interface IEnvironment { isExtensionDevelopmentDebug: boolean; @@ -799,8 +799,8 @@ export interface MainThreadTunnelServiceShape extends IDisposable { } export interface MainThreadTimelineShape extends IDisposable { - $registerTimelineProvider(key: string, id: string): void; - $unregisterTimelineProvider(key: string): void; + $registerTimelineProvider(provider: TimelineProviderDescriptor): void; + $unregisterTimelineProvider(source: string): void; $getTimeline(resource: UriComponents, since: number, token: CancellationToken): Promise; } @@ -1450,9 +1450,7 @@ export interface ExtHostTunnelServiceShape { } export interface ExtHostTimelineShape { - // $registerTimelineProvider(handle: number, provider: TimelineProvider): void; - - $getTimeline(key: string, uri: UriComponents, since: number, token: CancellationToken): Promise; + $getTimeline(source: string, uri: UriComponents, since: number, token: CancellationToken): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostTimeline.ts b/src/vs/workbench/api/common/extHostTimeline.ts index 65340c9ab9d..802803cf5cd 100644 --- a/src/vs/workbench/api/common/extHostTimeline.ts +++ b/src/vs/workbench/api/common/extHostTimeline.ts @@ -7,13 +7,15 @@ 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, TimelineProvider, toKey } from 'vs/workbench/contrib/timeline/common/timeline'; -import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { TimelineItem, 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'; export interface IExtHostTimeline extends ExtHostTimelineShape { readonly _serviceBrand: undefined; - $getTimeline(key: string, uri: UriComponents, since: number, token: vscode.CancellationToken): Promise; + $getTimeline(source: string, uri: UriComponents, since: number, token: vscode.CancellationToken): Promise; } export const IExtHostTimeline = createDecorator('IExtHostTimeline'); @@ -27,54 +29,86 @@ export class ExtHostTimeline implements IExtHostTimeline { constructor( mainContext: IMainContext, - ) { this._proxy = mainContext.getProxy(MainContext.MainThreadTimeline); + } - this.registerTimelineProvider('bar', { - id: 'baz', - async provideTimeline(uri: URI, since: number, token: vscode.CancellationToken) { - return [ - { - id: '1', - label: 'Bar Timeline1', - description: uri.toString(true), - detail: new Date().toString(), - date: Date.now(), - source: 'log' - }, - { - id: '2', - label: 'Bar Timeline2', - description: uri.toString(true), - detail: new Date(Date.now() - 100).toString(), - date: Date.now() - 100, - source: 'log' - } - ]; + async $getTimeline(source: string, uri: UriComponents, since: number, token: vscode.CancellationToken): Promise { + const provider = this._providers.get(source); + return provider?.provideTimeline(URI.revive(uri), since, token) ?? []; + } + + registerTimelineProvider(provider: vscode.TimelineProvider, commandConverter: CommandsConverter): IDisposable { + const disposables = new DisposableStore(); + + const convertTimelineItem = this.convertTimelineItem(provider.source, commandConverter, disposables); + return this.registerTimelineProviderCore({ + ...provider, + async provideTimeline(uri: URI, since: number, token: CancellationToken) { + disposables.clear(); + + const results = await provider.provideTimeline(uri, since, token); + // eslint-disable-next-line eqeqeq + return results != null + ? results.map(item => convertTimelineItem(item)) + : []; + }, + dispose() { + disposables.dispose(); } }); } - async $getTimeline(key: string, uri: UriComponents, since: number, token: vscode.CancellationToken): Promise { - const provider = this._providers.get(key); - return provider?.provideTimeline(URI.revive(uri), since, token) ?? []; + private convertTimelineItem(source: string, commandConverter: CommandsConverter, disposables: DisposableStore): (item: vscode.TimelineItem) => TimelineItemWithSource { + return (item: vscode.TimelineItem) => { + const { iconPath, ...props } = item; + + let icon; + let iconDark; + let themeIcon; + if (item.iconPath) { + if (iconPath instanceof ThemeIcon) { + themeIcon = { id: iconPath.id }; + } + else if (URI.isUri(iconPath)) { + icon = iconPath; + iconDark = iconPath; + } + else { + ({ light: icon, dark: iconDark } = iconPath as { light: URI; dark: URI }); + } + } + + return { + ...props, + source: source, + command: item.command ? commandConverter.toInternal(item.command, disposables) : undefined, + icon: icon, + iconDark: iconDark, + themeIcon: themeIcon + }; + }; } - registerTimelineProvider(extension: ExtensionIdentifier | string, provider: TimelineProvider): IDisposable { - console.log(`ExtHostTimeline#registerTimelineProvider: extension=${extension.toString()}, provider=${provider.id}`); + private registerTimelineProviderCore(provider: TimelineProvider): IDisposable { + console.log(`ExtHostTimeline#registerTimelineProvider: provider=${provider.source}`); - const key = toKey(extension, provider.id); - if (this._providers.has(key)) { - throw new Error(`Timeline Provider ${key} already exists.`); + const existing = this._providers.get(provider.source); + if (existing && !existing.replaceable) { + throw new Error(`Timeline Provider ${provider.source} already exists.`); } - this._proxy.$registerTimelineProvider(key, provider.id); - this._providers.set(key, provider); + this._proxy.$registerTimelineProvider({ + source: provider.source, + sourceDescription: provider.sourceDescription, + replaceable: provider.replaceable + }); + this._providers.set(provider.source, provider); return toDisposable(() => { - this._providers.delete(key); - this._proxy.$unregisterTimelineProvider(key); + this._providers.delete(provider.source); + this._proxy.$unregisterTimelineProvider(provider.source); + provider.dispose(); }); } } diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index bcab5a04e8d..28b901a1d8a 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -5,7 +5,7 @@ import 'vs/css!./media/timelinePane'; import * as nls from 'vs/nls'; -import * as dom from 'vs/base/browser/dom'; +import * as DOM from 'vs/base/browser/dom'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { FuzzyScore, createMatches } from 'vs/base/common/filters'; import { DisposableStore } from 'vs/base/common/lifecycle'; @@ -14,7 +14,7 @@ import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; import { IListVirtualDelegate, IIdentityProvider, IKeyboardNavigationLabelProvider } from 'vs/base/browser/ui/list/list'; import { ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPaneContainer'; -import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; +import { WorkbenchObjectTree, TreeResourceNavigator } from 'vs/platform/list/browser/listService'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -23,13 +23,15 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { TimelineItem, ITimelineService } 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'; +import { IThemeService, LIGHT, ThemeIcon } from 'vs/platform/theme/common/themeService'; import { IViewDescriptorService } from 'vs/workbench/common/views'; type TreeElement = TimelineItem; export class TimelinePane extends ViewPane { static readonly ID = 'timeline'; - static readonly TITLE = nls.localize('timeline', "Timeline"); + static readonly TITLE = nls.localize('timeline', 'Timeline'); private _tree!: WorkbenchObjectTree; private _tokenSource: CancellationTokenSource | undefined; private _visibilityDisposables: DisposableStore | undefined; @@ -43,7 +45,8 @@ export class TimelinePane extends ViewPane { @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @IInstantiationService protected readonly instantiationService: IInstantiationService, @IEditorService protected editorService: IEditorService, - @ITimelineService protected timelineService: ITimelineService, + @ICommandService protected commandService: ICommandService, + @ITimelineService protected timelineService: ITimelineService ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService); @@ -51,6 +54,21 @@ export class TimelinePane extends ViewPane { scopedContextKeyService.createKey('view', TimelinePane.ID); } + private onActiveEditorChanged() { + let uri; + + const editor = this.editorService.activeEditor; + if (editor) { + uri = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER }); + } + + this.updateUri(uri); + } + + private onProvidersChanged() { + this.refresh(); + } + private async refresh() { this._tokenSource?.cancel(); this._tokenSource = new CancellationTokenSource(); @@ -67,7 +85,6 @@ export class TimelinePane extends ViewPane { this._tree.setChildren(null, children); } - private _uri: URI | undefined; private updateUri(uri: URI | undefined) { @@ -79,21 +96,6 @@ export class TimelinePane extends ViewPane { this.refresh(); } - private onActiveEditorChanged() { - let uri; - - const editor = this.editorService.activeEditor; - if (editor) { - uri = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER }); - } - - this.updateUri(uri); - } - - private onProvidersChanged() { - this.refresh(); - } - focus(): void { super.focus(); this._tree.domFocus(); @@ -117,49 +119,58 @@ export class TimelinePane extends ViewPane { } protected renderBody(container: HTMLElement): void { - dom.addClass(container, '.tree-explorer-viewlet-tree-view'); + DOM.addClass(container, '.tree-explorer-viewlet-tree-view'); const treeContainer = document.createElement('div'); - dom.addClass(treeContainer, 'customview-tree'); - dom.addClass(treeContainer, 'file-icon-themable-tree'); - dom.addClass(treeContainer, 'show-file-icons'); + DOM.addClass(treeContainer, 'customview-tree'); + DOM.addClass(treeContainer, 'file-icon-themable-tree'); + DOM.addClass(treeContainer, 'show-file-icons'); container.appendChild(treeContainer); const renderer = this.instantiationService.createInstance(TimelineTreeRenderer); - this._tree = this.instantiationService.createInstance>( - WorkbenchObjectTree, - 'TimelinePane', - treeContainer, - new TimelineListVirtualDelegate(), - [renderer], - { - identityProvider: new TimelineIdentityProvider(), - keyboardNavigationLabelProvider: new TimelineKeyboardNavigationLabelProvider() - } + this._tree = this.instantiationService.createInstance< + typeof WorkbenchObjectTree, + WorkbenchObjectTree + >(WorkbenchObjectTree, 'TimelinePane', treeContainer, new TimelineListVirtualDelegate(), [renderer], { + identityProvider: new TimelineIdentityProvider(), + keyboardNavigationLabelProvider: new TimelineKeyboardNavigationLabelProvider() + }); + + const customTreeNavigator = new TreeResourceNavigator(this._tree, { openOnFocus: false, openOnSelection: false }); + this._register(customTreeNavigator); + this._register( + customTreeNavigator.onDidOpenResource(e => { + if (!e.browserEvent) { + return; + } + + const selection = this._tree.getSelection(); + const command = selection.length === 1 ? selection[0]?.command : undefined; + if (command) { + this.commandService.executeCommand(command.id, ...(command.arguments || [])); + } + }) ); } } - export class TimelineElementTemplate { static readonly id = 'TimelineElementTemplate'; constructor( readonly container: HTMLElement, readonly iconLabel: IconLabel, - // readonly iconClass: HTMLElement, - // readonly decoration: HTMLElement, + readonly icon: HTMLElement ) { } } export class TimelineIdentityProvider implements IIdentityProvider { - getId(item: TimelineItem): { toString(): string; } { + getId(item: TimelineItem): { toString(): string } { return `${item.id}|${item.date}`; } } - export class TimelineKeyboardNavigationLabelProvider implements IKeyboardNavigationLabelProvider { - getKeyboardNavigationLabel(element: TimelineItem): { toString(): string; } { + getKeyboardNavigationLabel(element: TimelineItem): { toString(): string } { return element.label; } } @@ -177,23 +188,46 @@ export class TimelineListVirtualDelegate implements IListVirtualDelegate { readonly templateId: string = TimelineElementTemplate.id; - constructor() { } + constructor(@IThemeService private _themeService: IThemeService) { } renderTemplate(container: HTMLElement): TimelineElementTemplate { - dom.addClass(container, 'custom-view-tree-node-item'); + DOM.addClass(container, 'custom-view-tree-node-item'); + const icon = DOM.append(container, DOM.$('.custom-view-tree-node-item-icon')); const iconLabel = new IconLabel(container, { supportHighlights: true, supportCodicons: true }); - return new TimelineElementTemplate(container, iconLabel); + return new TimelineElementTemplate(container, iconLabel, icon); } - renderElement(node: ITreeNode, index: number, template: TimelineElementTemplate, height: number | undefined): void { + renderElement( + node: ITreeNode, + index: number, + template: TimelineElementTemplate, + height: number | undefined + ): void { const { element } = node; - template.iconLabel.setLabel(element.label, element.description, { title: element.detail, matches: createMatches(node.filterData) }); + const icon = this._themeService.getTheme().type === LIGHT ? element.icon : element.iconDark; + const iconUrl = icon ? URI.revive(icon) : null; + + if (iconUrl) { + template.icon.className = 'custom-view-tree-node-item-icon'; + template.icon.style.backgroundImage = DOM.asCSSUrl(iconUrl); + + } else { + let iconClass: string | undefined; + if (element.themeIcon /*&& !this.isFileKindThemeIcon(element.themeIcon)*/) { + iconClass = ThemeIcon.asClassName(element.themeIcon); + } + template.icon.className = iconClass ? `custom-view-tree-node-item-icon ${iconClass}` : ''; + } + + template.iconLabel.setLabel(element.label, element.description, { + title: element.detail, + matches: createMatches(node.filterData) + }); } disposeTemplate(template: TimelineElementTemplate): void { template.iconLabel.dispose(); } } - diff --git a/src/vs/workbench/contrib/timeline/common/timeline.ts b/src/vs/workbench/contrib/timeline/common/timeline.ts index 223648a8915..aae6dd4d3dd 100644 --- a/src/vs/workbench/contrib/timeline/common/timeline.ts +++ b/src/vs/workbench/contrib/timeline/common/timeline.ts @@ -7,6 +7,7 @@ import { CancellationToken } 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'; +import { Command } from 'vs/editor/common/modes'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -16,33 +17,39 @@ export function toKey(extension: ExtensionIdentifier | string, source: string) { export interface TimelineItem { date: number; - source: string; label: string; id?: string; - // iconPath?: string | Uri | { light: string | Uri; dark: string | Uri } | ThemeIcon; + icon?: URI, + iconDark?: URI, + themeIcon?: { id: string }, description?: string; detail?: string; - - // resourceUri?: Uri; - // tooltip?: string | undefined; - // command?: Command; - // collapsibleState?: TreeItemCollapsibleState; - // contextValue?: string; + command?: Command; + contextValue?: string; } -export interface TimelineProvider { - id: string; - // selector: DocumentSelector; +export interface TimelineItemWithSource extends TimelineItem { + source: string; +} +export interface TimelineProvider extends TimelineProviderDescriptor, IDisposable { provideTimeline(uri: URI, since: number, token: CancellationToken): Promise; } +export interface TimelineProviderDescriptor { + source: string; + sourceDescription: string; + + replaceable?: boolean; + // selector: DocumentSelector; +} + export interface ITimelineService { readonly _serviceBrand: undefined; onDidChangeProviders: Event; - registerTimelineProvider(key: string, provider: TimelineProvider): IDisposable; - unregisterTimelineProvider(key: string): void; + registerTimelineProvider(provider: TimelineProvider): IDisposable; + unregisterTimelineProvider(source: string): void; getTimeline(uri: URI, since: number, token: CancellationToken): Promise; } diff --git a/src/vs/workbench/contrib/timeline/common/timelineService.ts b/src/vs/workbench/contrib/timeline/common/timelineService.ts index d66f28752c3..210c5ee6e77 100644 --- a/src/vs/workbench/contrib/timeline/common/timelineService.ts +++ b/src/vs/workbench/contrib/timeline/common/timelineService.ts @@ -19,88 +19,95 @@ export class TimelineService implements ITimelineService { private readonly _providers = new Map(); constructor(@ILogService private readonly logService: ILogService) { - this.registerTimelineProvider('foo', { - id: 'bar', + this.registerTimelineProvider({ + source: 'local-history', + sourceDescription: 'Local History', async provideTimeline(uri: URI, since: number, token: CancellationToken) { return [ { id: '1', - label: '$(git-commit) Timeline1', + label: 'Undo Timeline1', description: uri.toString(true), - date: Date.now(), - source: 'internal' + date: Date.now() }, { id: '2', - label: '$(git-commit) Timeline2', + label: 'Undo Timeline2', description: uri.toString(true), - date: Date.now() - 100, - source: 'internal' + date: Date.now() - 100 } - ]; - } + }, + dispose() { } }); } + // TODO: Add filtering async getTimeline(uri: URI, since: number, token: CancellationToken) { this.logService.trace(`TimelineService#getTimeline: uri=${uri.toString(true)}`); const requests = []; for (const provider of this._providers.values()) { - requests.push(provider.provideTimeline(uri, since, token)); + requests.push( + provider.provideTimeline(uri, since, token).then(items => ({ source: provider.source, items: items })) + ); } - const timelines = await raceAll(requests, 5000); + const timelines = await raceAll(requests /*, 5000*/); const timeline = []; - for (const items of timelines) { + for (const result of timelines) { // eslint-disable-next-line eqeqeq - if (items == null || items instanceof CancellationError || items.length === 0) { + if (result == null || result instanceof CancellationError) { continue; } - timeline.push(...items); + const { source, items } = result; + if (items.length === 0) { + continue; + } + + timeline.push(...items.map(item => ({ ...item, source: source }))); } timeline.sort((a, b) => b.date - a.date); return timeline; } - registerTimelineProvider(key: string, provider: TimelineProvider): IDisposable { - this.logService.trace('TimelineService#registerTimelineProvider'); + registerTimelineProvider(provider: TimelineProvider): IDisposable { + this.logService.trace(`TimelineService#registerTimelineProvider: provider=${provider.source}`); - if (this._providers.has(key)) { - throw new Error(`Timeline Provider ${key} already exists.`); + const source = provider.source; + + const existing = this._providers.get(source); + if (existing && !existing.replaceable) { + throw new Error(`Timeline Provider ${source} already exists.`); } - this._providers.set(key, provider); + this._providers.set(source, provider); this._onDidChangeProviders.fire(); return { dispose: () => { - this._providers.delete(key); + this._providers.delete(source); this._onDidChangeProviders.fire(); } }; } - unregisterTimelineProvider(key: string): void { + unregisterTimelineProvider(source: string): void { this.logService.trace('TimelineService#unregisterTimelineProvider'); - if (!this._providers.has(key)) { + if (!this._providers.has(source)) { return; } - this._providers.delete(key); + this._providers.delete(source); this._onDidChangeProviders.fire(); } } -function* map( - source: Iterable | IterableIterator, - mapper: (item: T) => TMapped -): Iterable { +function* map(source: Iterable | IterableIterator, mapper: (item: T) => TMapped): Iterable { for (const item of source) { yield mapper(item); } @@ -150,10 +157,7 @@ async function raceAll( if (promises instanceof Map) { return new Map( await Promise.all( - map< - [T, Promise], - Promise<[T, TPromise | CancellationErrorWithId>]> - >( + map<[T, Promise], Promise<[T, TPromise | CancellationErrorWithId>]>>( promises.entries(), // eslint-disable-next-line eqeqeq timeout == null @@ -163,10 +167,7 @@ async function raceAll( promise, new Promise>>(resolve => - setTimeout( - () => resolve(new CancellationErrorWithId(id, promise, 'TIMED OUT')), - timeout! - ) + setTimeout(() => resolve(new CancellationErrorWithId(id, promise, 'TIMED OUT')), timeout!) ) ]).then(p => [id, p]) )