提交 19ad0184 编写于 作者: E Eric Amodio

Enhances timeline - commands, timestamp, etc

Adds contributable commands to timeline items
Adds right-aligned timestamp to timeline items
Adds Open Changes to Git timeline items
Adds Copy Commit ID to Git timeline items
Adds Copy Commit Message to Git timeline items
上级 9e4b684c
......@@ -447,6 +447,22 @@
"command": "git.stashDrop",
"title": "%command.stashDrop%",
"category": "Git"
},
{
"command": "git.timeline.openDiff",
"title": "%command.timelineOpenDiff%",
"icon": "$(compare-changes)",
"category": "Git"
},
{
"command": "git.timeline.copyCommitId",
"title": "%command.timelineCopyCommitId%",
"category": "Git"
},
{
"command": "git.timeline.copyCommitMessage",
"title": "%command.timelineCopyCommitMessage%",
"category": "Git"
}
],
"menus": {
......@@ -718,6 +734,18 @@
{
"command": "git.stashDrop",
"when": "config.git.enabled && gitOpenRepositoryCount != 0"
},
{
"command": "git.timeline.openDiff",
"when": "false"
},
{
"command": "git.timeline.copyCommitId",
"when": "false"
},
{
"command": "git.timeline.copyCommitMessage",
"when": "false"
}
],
"scm/title": [
......@@ -1248,6 +1276,28 @@
"command": "git.revertChange",
"when": "originalResourceScheme == git"
}
],
"timeline/item/context": [
{
"command": "git.timeline.openDiff",
"group": "inline",
"when": "timelineItem =~ /git:file\\b/"
},
{
"command": "git.timeline.openDiff",
"group": "1_timeline",
"when": "timelineItem =~ /git:file\\b/"
},
{
"command": "git.timeline.copyCommitId",
"group": "2_timeline@1",
"when": "timelineItem =~ /git:file:commit\\b/"
},
{
"command": "git.timeline.copyCommitMessage",
"group": "2_timeline@2",
"when": "timelineItem =~ /git:file:commit\\b/"
}
]
},
"configuration": {
......@@ -1779,10 +1829,10 @@
"@types/file-type": "^5.2.1",
"@types/mocha": "2.2.43",
"@types/node": "^12.11.7",
"@types/vscode": "^1.42",
"@types/which": "^1.0.28",
"mocha": "^3.2.0",
"mocha-junit-reporter": "^1.23.3",
"mocha-multi-reporters": "^1.1.7",
"vscode": "^1.1.36"
"mocha-multi-reporters": "^1.1.7"
}
}
......@@ -70,6 +70,9 @@
"command.stashApply": "Apply Stash...",
"command.stashApplyLatest": "Apply Latest Stash",
"command.stashDrop": "Drop Stash...",
"command.timelineOpenDiff": "Open Changes",
"command.timelineCopyCommitId": "Copy Commit ID",
"command.timelineCopyCommitMessage": "Copy Commit Message",
"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.",
......
......@@ -6,7 +6,7 @@
import { lstat, Stats } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { commands, Disposable, LineChange, MessageOptions, OutputChannel, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder } from 'vscode';
import { commands, Disposable, LineChange, MessageOptions, OutputChannel, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env } from 'vscode';
import TelemetryReporter from 'vscode-extension-telemetry';
import * as nls from 'vscode-nls';
import { Branch, GitErrorCodes, Ref, RefType, Status, CommitOptions } from './api/git';
......@@ -17,6 +17,7 @@ import { applyLineChanges, getModifiedRange, intersectDiffWithRange, invertLineC
import { fromGitUri, toGitUri, isGitUri } from './uri';
import { grep, isDescendant, pathEquals } from './util';
import { Log, LogLevel } from './log';
import { GitTimelineItem } from './timelineProvider';
const localize = nls.loadMessageBundle();
......@@ -2331,23 +2332,47 @@ export class CommandCenter {
return result && result.stash;
}
@command('git.openDiff', { repository: false })
async openDiff(uri: Uri, lhs: string, rhs: string) {
@command('git.timeline.openDiff', { repository: false })
async timelineOpenDiff(item: TimelineItem, uri: Uri | undefined, _source: string) {
// eslint-disable-next-line eqeqeq
if (uri == null || !GitTimelineItem.is(item)) {
return undefined;
}
const basename = path.basename(uri.fsPath);
let title;
if ((lhs === 'HEAD' || lhs === '~') && rhs === '') {
if ((item.previousRef === 'HEAD' || item.previousRef === '~') && item.ref === '') {
title = `${basename} (Working Tree)`;
}
else if (lhs === 'HEAD' && rhs === '~') {
else if (item.previousRef === 'HEAD' && item.ref === '~') {
title = `${basename} (Index)`;
} else {
title = `${basename} (${lhs.endsWith('^') ? `${lhs.substr(0, 8)}^` : lhs.substr(0, 8)}) \u27f7 ${basename} (${rhs.endsWith('^') ? `${rhs.substr(0, 8)}^` : rhs.substr(0, 8)})`;
title = `${basename} (${item.shortPreviousRef}) \u27f7 ${basename} (${item.shortRef})`;
}
return commands.executeCommand('vscode.diff', toGitUri(uri, item.previousRef), item.ref === '' ? uri : toGitUri(uri, item.ref), title);
}
@command('git.timeline.copyCommitId', { repository: false })
async timelineCopyCommitId(item: TimelineItem, _uri: Uri | undefined, _source: string) {
if (!GitTimelineItem.is(item)) {
return;
}
return commands.executeCommand('vscode.diff', toGitUri(uri, lhs), rhs === '' ? uri : toGitUri(uri, rhs), title);
env.clipboard.writeText(item.ref);
}
@command('git.timeline.copyCommitMessage', { repository: false })
async timelineCopyCommitMessage(item: TimelineItem, _uri: Uri | undefined, _source: string) {
if (!GitTimelineItem.is(item)) {
return;
}
env.clipboard.writeText(item.message);
}
private createCommand(id: string, key: string, method: Function, options: CommandOptions): (...args: any[]) => any {
const result = (...args: any[]) => {
let result: Promise<any>;
......
......@@ -5,7 +5,6 @@
import * as dayjs from 'dayjs';
import * as advancedFormat from 'dayjs/plugin/advancedFormat';
import * as relativeTime from 'dayjs/plugin/relativeTime';
import { CancellationToken, Disposable, Event, EventEmitter, ThemeIcon, Timeline, TimelineChangeEvent, TimelineCursor, TimelineItem, TimelineProvider, Uri, workspace } from 'vscode';
import { Model } from './model';
import { Repository } from './repository';
......@@ -13,11 +12,55 @@ import { debounce } from './decorators';
import { Status } from './api/git';
dayjs.extend(advancedFormat);
dayjs.extend(relativeTime);
// TODO[ECA]: Localize all the strings
// TODO[ECA]: Localize or use a setting for date format
export class GitTimelineItem extends TimelineItem {
static is(item: TimelineItem): item is GitTimelineItem {
return item instanceof GitTimelineItem;
}
readonly ref: string;
readonly previousRef: string;
readonly message: string;
constructor(
ref: string,
previousRef: string,
message: string,
timestamp: number,
id: string,
contextValue: string
) {
const index = message.indexOf('\n');
const label = index !== -1 ? `${message.substring(0, index)} \u2026` : message;
super(label, timestamp);
this.ref = ref;
this.previousRef = previousRef;
this.message = message;
this.id = id;
this.contextValue = contextValue;
}
get shortRef() {
return this.shortenRef(this.ref);
}
get shortPreviousRef() {
return this.shortenRef(this.previousRef);
}
private shortenRef(ref: string): string {
if (ref === '' || ref === '~' || ref === 'HEAD') {
return ref;
}
return ref.endsWith('^') ? `${ref.substr(0, 8)}^` : ref.substr(0, 8);
}
}
export class GitTimelineProvider implements TimelineProvider {
private _onDidChange = new EventEmitter<TimelineChangeEvent>();
get onDidChange(): Event<TimelineChangeEvent> {
......@@ -72,25 +115,17 @@ export class GitTimelineProvider implements TimelineProvider {
const commits = await repo.logFile(uri);
let dateFormatter: dayjs.Dayjs;
const items = commits.map<TimelineItem>(c => {
let message = c.message;
const index = message.indexOf('\n');
if (index !== -1) {
message = `${message.substring(0, index)} \u2026`;
}
const items = commits.map<GitTimelineItem>(c => {
dateFormatter = dayjs(c.authorDate);
const item = new TimelineItem(message, c.authorDate?.getTime() ?? 0);
item.id = c.hash;
const item = new GitTimelineItem(c.hash, `${c.hash}^`, c.message, c.authorDate?.getTime() ?? 0, c.hash, 'git:file:commit');
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = `${dateFormatter.fromNow()} \u2022 ${c.authorName}`;
item.detail = `${c.authorName} (${c.authorEmail}) \u2014 ${c.hash.substr(0, 8)}\n${dateFormatter.fromNow()} (${dateFormatter.format('MMMM Do, YYYY h:mma')})\n\n${c.message}`;
item.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 Diff',
command: 'git.openDiff',
arguments: [uri, `${c.hash}^`, c.hash]
title: 'Open Comparison',
command: 'git.timeline.openDiff',
arguments: [uri, this.id, item]
};
return item;
......@@ -123,16 +158,15 @@ export class GitTimelineProvider implements TimelineProvider {
break;
}
const item = new TimelineItem('Staged Changes', date.getTime());
item.id = 'index';
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 = `${dateFormatter.fromNow()} \u2022 You`;
item.detail = `You \u2014 Index\n${dateFormatter.fromNow()} (${dateFormatter.format('MMMM Do, YYYY h:mma')})\n${status}`;
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.openDiff',
arguments: [uri, 'HEAD', '~']
command: 'git.timeline.openDiff',
arguments: [uri, this.id, item]
};
items.push(item);
......@@ -166,16 +200,15 @@ export class GitTimelineProvider implements TimelineProvider {
break;
}
const item = new TimelineItem('Uncommited Changes', date.getTime());
item.id = 'working';
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 = `${dateFormatter.fromNow()} \u2022 You`;
item.detail = `You \u2014 Working Tree\n${dateFormatter.fromNow()} (${dateFormatter.format('MMMM Do, YYYY h:mma')})\n${status}`;
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.openDiff',
arguments: [uri, index ? '~' : 'HEAD', '']
command: 'git.timeline.openDiff',
arguments: [uri, this.id, item]
};
items.push(item);
......@@ -208,6 +241,6 @@ export class GitTimelineProvider implements TimelineProvider {
@debounce(500)
private fireChanged() {
this._onDidChange.fire();
this._onDidChange.fire({});
}
}
此差异已折叠。
......@@ -5,6 +5,53 @@
import { pad } from './strings';
const minute = 60;
const hour = minute * 60;
const day = hour * 24;
const week = day * 7;
const month = day * 30;
const year = day * 365;
// TODO[ECA]: Localize strings
export function fromNow(date: number | Date) {
if (typeof date !== 'number') {
date = date.getTime();
}
const seconds = Math.round((new Date().getTime() - date) / 1000);
if (seconds < 30) {
return 'now';
}
let value: number;
let unit: string;
if (seconds < minute) {
value = seconds;
unit = 'sec';
} else if (seconds < hour) {
value = Math.floor(seconds / minute);
unit = 'min';
} else if (seconds < day) {
value = Math.floor(seconds / hour);
unit = 'hr';
} else if (seconds < week) {
value = Math.floor(seconds / day);
unit = 'day';
} else if (seconds < month) {
value = Math.floor(seconds / week);
unit = 'wk';
} else if (seconds < year) {
value = Math.floor(seconds / month);
unit = 'mo';
} else {
value = Math.floor(seconds / year);
unit = 'yr';
}
return `${value} ${unit}${value === 1 ? '' : 's'}`;
}
export function toLocalISOString(date: Date): string {
return date.getFullYear() +
'-' + pad(date.getMonth() + 1, 2) +
......
......@@ -114,7 +114,9 @@ export class MenuId {
static readonly CommentActions = new MenuId('CommentActions');
static readonly BulkEditTitle = new MenuId('BulkEditTitle');
static readonly BulkEditContext = new MenuId('BulkEditContext');
static readonly TimelineItemContext = new MenuId('TimelineItemContext');
static readonly TimelineTitle = new MenuId('TimelineTitle');
static readonly TimelineTitleContext = new MenuId('TimelineTitleContext');
readonly id: number;
readonly _debugName: string;
......
......@@ -39,8 +39,8 @@ export class MainThreadTimeline implements MainThreadTimelineShape {
this._timelineService.registerTimelineProvider({
...provider,
onDidChange: onDidChange.event,
provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken) {
return proxy.$getTimeline(provider.id, uri, cursor, token);
provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }) {
return proxy.$getTimeline(provider.id, uri, cursor, token, options);
},
dispose() {
emitters.delete(provider.id);
......
......@@ -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 extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol));
const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands));
// Check that no named customers are missing
const expected: ProxyIdentifier<any>[] = values(ExtHostContext);
......
......@@ -1457,7 +1457,7 @@ export interface ExtHostTunnelServiceShape {
}
export interface ExtHostTimelineShape {
$getTimeline(source: string, uri: UriComponents, cursor: TimelineCursor, token: CancellationToken): Promise<Timeline | undefined>;
$getTimeline(source: string, uri: UriComponents, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
}
// --- proxy identifiers
......
......@@ -7,55 +7,77 @@ 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, TimelineItemWithSource, TimelineProvider } from 'vs/workbench/contrib/timeline/common/timeline';
import { Timeline, TimelineCursor, TimelineItem, 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 { CommandsConverter, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands';
import { ThemeIcon } from 'vs/workbench/api/common/extHostTypes';
import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
export interface IExtHostTimeline extends ExtHostTimelineShape {
readonly _serviceBrand: undefined;
$getTimeline(id: string, uri: UriComponents, cursor: vscode.TimelineCursor, token: vscode.CancellationToken): Promise<Timeline | undefined>;
$getTimeline(id: string, uri: UriComponents, cursor: vscode.TimelineCursor, token: vscode.CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
}
export const IExtHostTimeline = createDecorator<IExtHostTimeline>('IExtHostTimeline');
export class ExtHostTimeline implements IExtHostTimeline {
private static handlePool = 0;
_serviceBrand: undefined;
private _proxy: MainThreadTimelineShape;
private _providers = new Map<string, TimelineProvider>();
private _itemsBySourceByUriMap = new Map<string | undefined, Map<string, Map<string, vscode.TimelineItem>>>();
constructor(
mainContext: IMainContext,
commands: ExtHostCommands,
) {
this._proxy = mainContext.getProxy(MainContext.MainThreadTimeline);
commands.registerArgumentProcessor({
processArgument: arg => {
if (arg && arg.$mid === 11) {
const uri = arg.uri === undefined ? undefined : URI.revive(arg.uri);
return this._itemsBySourceByUriMap.get(getUriKey(uri))?.get(arg.source)?.get(arg.handle);
}
return arg;
}
});
}
async $getTimeline(id: string, uri: UriComponents, cursor: vscode.TimelineCursor, token: vscode.CancellationToken): Promise<Timeline | undefined> {
async $getTimeline(id: string, uri: UriComponents, cursor: vscode.TimelineCursor, token: vscode.CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined> {
const provider = this._providers.get(id);
return provider?.provideTimeline(URI.revive(uri), cursor, token);
return provider?.provideTimeline(URI.revive(uri), cursor, token, options);
}
registerTimelineProvider(scheme: string | string[], provider: vscode.TimelineProvider, extensionId: ExtensionIdentifier, commandConverter: CommandsConverter): IDisposable {
registerTimelineProvider(scheme: string | string[], provider: vscode.TimelineProvider, _extensionId: ExtensionIdentifier, commandConverter: CommandsConverter): IDisposable {
const timelineDisposables = new DisposableStore();
const convertTimelineItem = this.convertTimelineItem(provider.id, commandConverter, timelineDisposables);
const convertTimelineItem = this.convertTimelineItem(provider.id, commandConverter, timelineDisposables).bind(this);
let disposable: IDisposable | undefined;
if (provider.onDidChange) {
disposable = provider.onDidChange(this.emitTimelineChangeEvent(provider.id), this);
}
const itemsBySourceByUriMap = this._itemsBySourceByUriMap;
return this.registerTimelineProviderCore({
...provider,
scheme: scheme,
onDidChange: undefined,
async provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken) {
async provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }) {
timelineDisposables.clear();
// For now, only allow the caching of a single Uri
if (options?.cacheResults && !itemsBySourceByUriMap.has(getUriKey(uri))) {
itemsBySourceByUriMap.clear();
}
const result = await provider.provideTimeline(uri, cursor, token);
// Intentional == we don't know how a provider will respond
// eslint-disable-next-line eqeqeq
......@@ -63,10 +85,12 @@ export class ExtHostTimeline implements IExtHostTimeline {
return undefined;
}
// TODO: Determine if we should cache dependent on who calls us (internal vs external)
const convertItem = convertTimelineItem(uri, options?.cacheResults ?? false);
return {
...result,
source: provider.id,
items: result.items.map(convertTimelineItem)
items: result.items.map(convertItem)
};
},
dispose() {
......@@ -76,39 +100,72 @@ export class ExtHostTimeline implements IExtHostTimeline {
});
}
private convertTimelineItem(source: string, commandConverter: CommandsConverter, disposables: DisposableStore): (item: vscode.TimelineItem) => TimelineItemWithSource {
return (item: vscode.TimelineItem) => {
const { iconPath, ...props } = item;
private convertTimelineItem(source: string, commandConverter: CommandsConverter, disposables: DisposableStore) {
return (uri: URI, cacheResults: boolean) => {
let itemsMap: Map<string, vscode.TimelineItem> | undefined;
if (cacheResults) {
const uriKey = getUriKey(uri);
let icon;
let iconDark;
let themeIcon;
if (item.iconPath) {
if (iconPath instanceof ThemeIcon) {
themeIcon = { id: iconPath.id };
let sourceMap = this._itemsBySourceByUriMap.get(uriKey);
if (sourceMap === undefined) {
sourceMap = new Map();
this._itemsBySourceByUriMap.set(uriKey, sourceMap);
}
else if (URI.isUri(iconPath)) {
icon = iconPath;
iconDark = iconPath;
}
else {
({ light: icon, dark: iconDark } = iconPath as { light: URI; dark: URI });
itemsMap = sourceMap.get(source);
if (itemsMap === undefined) {
itemsMap = new Map();
sourceMap.set(source, itemsMap);
}
}
return {
...props,
source: source,
command: item.command ? commandConverter.toInternal(item.command, disposables) : undefined,
icon: icon,
iconDark: iconDark,
themeIcon: themeIcon
return (item: vscode.TimelineItem): TimelineItem => {
const { iconPath, ...props } = item;
const handle = `${source}|${item.id ?? `${item.timestamp}-${ExtHostTimeline.handlePool++}`}`;
itemsMap?.set(handle, 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,
handle: handle,
source: source,
command: item.command ? commandConverter.toInternal(item.command, disposables) : undefined,
icon: icon,
iconDark: iconDark,
themeIcon: themeIcon
};
};
};
}
private emitTimelineChangeEvent(id: string) {
return (e: vscode.TimelineChangeEvent) => {
// Clear caches
if (e?.uri === undefined) {
for (const sourceMap of this._itemsBySourceByUriMap.values()) {
sourceMap.get(id)?.clear();
}
}
else {
this._itemsBySourceByUriMap.get(getUriKey(e.uri))?.clear();
}
this._proxy.$emitTimelineChangeEvent({ ...e, id: id });
};
}
......@@ -129,9 +186,18 @@ export class ExtHostTimeline implements IExtHostTimeline {
this._providers.set(provider.id, provider);
return toDisposable(() => {
for (const sourceMap of this._itemsBySourceByUriMap.values()) {
sourceMap.get(provider.id)?.clear();
}
this._providers.delete(provider.id);
this._proxy.$unregisterTimelineProvider(provider.id);
provider.dispose();
});
}
}
function getUriKey(uri: URI | undefined): string | undefined {
return uri?.toString();
}
......@@ -52,6 +52,8 @@ namespace schema {
case 'comments/comment/title': return MenuId.CommentTitle;
case 'comments/comment/context': return MenuId.CommentActions;
case 'extension/context': return MenuId.ExtensionContext;
case 'timeline/title': return MenuId.TimelineTitle;
case 'timeline/item/context': return MenuId.TimelineItemContext;
}
return undefined;
......@@ -215,6 +217,16 @@ namespace schema {
type: 'array',
items: menuItem
},
'timeline/title': {
description: localize('view.timelineTitle', "The Timeline view title menu"),
type: 'array',
items: menuItem
},
'timeline/item/context': {
description: localize('view.timelineContext', "The Timeline view item context menu"),
type: 'array',
items: menuItem
},
}
};
......
......@@ -13,3 +13,20 @@
position: absolute;
pointer-events: none;
}
.timeline-tree-view .monaco-list .monaco-list-row .custom-view-tree-node-item .monaco-icon-label {
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
}
.timeline-tree-view .monaco-list .monaco-list-row .custom-view-tree-node-item .timeline-timestamp-container {
margin-left: 2px;
margin-right: 4px;
text-overflow: ellipsis;
overflow: hidden;
}
.timeline-tree-view .monaco-list .monaco-list-row .custom-view-tree-node-item .timeline-timestamp-container .timeline-timestamp {
opacity: 0.5;
}
......@@ -8,11 +8,11 @@ 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 { DisposableStore } from 'vs/base/common/lifecycle';
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';
import { IListVirtualDelegate, IIdentityProvider, IKeyboardNavigationLabelProvider } from 'vs/base/browser/ui/list/list';
import { ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree';
import { ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from 'vs/base/browser/ui/tree/tree';
import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPaneContainer';
import { TreeResourceNavigator, WorkbenchObjectTree } from 'vs/platform/list/browser/listService';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
......@@ -20,7 +20,7 @@ import { IContextMenuService } from 'vs/platform/contextview/browser/contextView
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { TimelineItem, ITimelineService, TimelineChangeEvent, TimelineProvidersChangeEvent, TimelineRequest, TimelineItemWithSource } from 'vs/workbench/contrib/timeline/common/timeline';
import { ITimelineService, TimelineChangeEvent, TimelineProvidersChangeEvent, TimelineRequest, TimelineItem } 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';
......@@ -31,10 +31,20 @@ 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';
import { IAction, ActionRunner } from 'vs/base/common/actions';
import { ContextAwareMenuEntryActionViewItem, createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { MenuItemAction, IMenuService, MenuId } from 'vs/platform/actions/common/actions';
import { fromNow } from 'vs/base/common/date';
// TODO[ECA]: Localize all the strings
type TreeElement = TimelineItem;
// TODO[ECA]: Localize all the strings
interface TimelineActionContext {
uri: URI | undefined;
item: TreeElement;
}
export class TimelinePane extends ViewPane {
static readonly ID = 'timeline';
......@@ -44,10 +54,12 @@ export class TimelinePane extends ViewPane {
private _messageElement!: HTMLDivElement;
private _treeElement!: HTMLDivElement;
private _tree!: WorkbenchObjectTree<TreeElement, FuzzyScore>;
private _treeRenderer: TimelineTreeRenderer | undefined;
private _menus: TimelineMenus;
private _visibilityDisposables: DisposableStore | undefined;
// private _excludedSources: Set<string> | undefined;
private _items: TimelineItemWithSource[] = [];
private _items: TimelineItem[] = [];
private _loadingMessageTimer: any | undefined;
private _pendingRequests = new Map<string, TimelineRequest>();
private _uri: URI | undefined;
......@@ -67,7 +79,9 @@ export class TimelinePane extends ViewPane {
@IOpenerService openerService: IOpenerService,
@IThemeService themeService: IThemeService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService);
super({ ...options, titleMenuId: MenuId.TimelineTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService);
this._menus = this._register(this.instantiationService.createInstance(TimelineMenus, this.id));
const scopedContextKeyService = this._register(this.contextKeyService.createScoped());
scopedContextKeyService.createKey('view', TimelinePane.ID);
......@@ -88,6 +102,7 @@ export class TimelinePane extends ViewPane {
}
this._uri = uri;
this._treeRenderer?.setUri(uri);
this.loadTimeline();
}
......@@ -187,7 +202,7 @@ export class TimelinePane extends ViewPane {
let request = this._pendingRequests.get(source);
request?.tokenSource.dispose(true);
request = this.timelineService.getTimeline(source, this._uri, {}, new CancellationTokenSource())!;
request = this.timelineService.getTimeline(source, this._uri, {}, new CancellationTokenSource(), { cacheResults: true })!;
this._pendingRequests.set(source, request);
request.tokenSource.token.onCancellationRequested(() => this._pendingRequests.delete(source));
......@@ -211,7 +226,7 @@ export class TimelinePane extends ViewPane {
this.replaceItems(request.source, items);
}
private replaceItems(source: string, items?: TimelineItemWithSource[]) {
private replaceItems(source: string, items?: TimelineItem[]) {
const hasItems = this._items.length !== 0;
if (items?.length) {
......@@ -291,17 +306,20 @@ export class TimelinePane extends ViewPane {
// DOM.addClass(this._treeElement, 'show-file-icons');
container.appendChild(this._treeElement);
const renderer = this.instantiationService.createInstance(TimelineTreeRenderer);
this._tree = <WorkbenchObjectTree<TreeElement, FuzzyScore>>this.instantiationService.createInstance(WorkbenchObjectTree, 'TimelinePane', this._treeElement, new TimelineListVirtualDelegate(), [renderer], {
this._treeRenderer = this.instantiationService.createInstance(TimelineTreeRenderer, this._menus);
this._tree = <WorkbenchObjectTree<TreeElement, FuzzyScore>>this.instantiationService.createInstance(WorkbenchObjectTree, 'TimelinePane',
this._treeElement, new TimelineListVirtualDelegate(), [this._treeRenderer], {
identityProvider: new TimelineIdentityProvider(),
keyboardNavigationLabelProvider: new TimelineKeyboardNavigationLabelProvider(),
overrideStyles: {
listBackground: this.getBackgroundColor()
listBackground: this.getBackgroundColor(),
}
});
const customTreeNavigator = new TreeResourceNavigator(this._tree, { openOnFocus: false, openOnSelection: false });
this._register(customTreeNavigator);
this._register(this._tree.onContextMenu(e => this.onContextMenu(this._menus, e)));
this._register(
customTreeNavigator.onDidOpenResource(e => {
if (!e.browserEvent) {
......@@ -316,36 +334,112 @@ export class TimelinePane extends ViewPane {
})
);
}
private onContextMenu(menus: TimelineMenus, treeEvent: ITreeContextMenuEvent<TreeElement | null>): void {
const item = treeEvent.element;
if (item === null) {
return;
}
const event: UIEvent = treeEvent.browserEvent;
event.preventDefault();
event.stopPropagation();
this._tree.setFocus([item]);
const actions = menus.getResourceContextActions(item);
if (!actions.length) {
return;
}
this.contextMenuService.showContextMenu({
getAnchor: () => treeEvent.anchor,
getActions: () => actions,
getActionViewItem: (action) => {
const keybinding = this.keybindingService.lookupKeybinding(action.id);
if (keybinding) {
return new ActionViewItem(action, action, { label: true, keybinding: keybinding.getLabel() });
}
return undefined;
},
onHide: (wasCancelled?: boolean) => {
if (wasCancelled) {
this._tree.domFocus();
}
},
getActionsContext: (): TimelineActionContext => ({ uri: this._uri, item: item }),
actionRunner: new TimelineActionRunner()
});
}
}
export class TimelineElementTemplate {
export class TimelineElementTemplate implements IDisposable {
static readonly id = 'TimelineElementTemplate';
readonly actionBar: ActionBar;
readonly icon: HTMLElement;
readonly iconLabel: IconLabel;
readonly timestamp: HTMLSpanElement;
constructor(
readonly container: HTMLElement,
readonly iconLabel: IconLabel,
readonly icon: HTMLElement
) { }
actionViewItemProvider: IActionViewItemProvider
) {
DOM.addClass(container, 'custom-view-tree-node-item');
this.icon = DOM.append(container, DOM.$('.custom-view-tree-node-item-icon'));
this.iconLabel = new IconLabel(container, { supportHighlights: true, supportCodicons: true });
const timestampContainer = DOM.append(this.iconLabel.element, DOM.$('.timeline-timestamp-container'));
this.timestamp = DOM.append(timestampContainer, DOM.$('span.timeline-timestamp'));
const actionsContainer = DOM.append(this.iconLabel.element, DOM.$('.actions'));
this.actionBar = new ActionBar(actionsContainer, { actionViewItemProvider: actionViewItemProvider });
}
dispose() {
this.iconLabel.dispose();
this.actionBar.dispose();
}
reset() {
this.actionBar.clear();
}
}
export class TimelineIdentityProvider implements IIdentityProvider<TreeElement> {
getId(item: TreeElement): { toString(): string } {
return item.handle;
}
}
export class TimelineIdentityProvider implements IIdentityProvider<TimelineItem> {
getId(item: TimelineItem): { toString(): string } {
return `${item.id}|${item.timestamp}`;
class TimelineActionRunner extends ActionRunner {
runAction(action: IAction, { uri, item }: TimelineActionContext): Promise<any> {
return action.run(...[
{
$mid: 11,
handle: item.handle,
source: item.source,
uri: uri
},
uri,
item.source,
]);
}
}
export class TimelineKeyboardNavigationLabelProvider implements IKeyboardNavigationLabelProvider<TimelineItem> {
getKeyboardNavigationLabel(element: TimelineItem): { toString(): string } {
export class TimelineKeyboardNavigationLabelProvider implements IKeyboardNavigationLabelProvider<TreeElement> {
getKeyboardNavigationLabel(element: TreeElement): { toString(): string } {
return element.label;
}
}
export class TimelineListVirtualDelegate implements IListVirtualDelegate<TimelineItem> {
getHeight(_element: TimelineItem): number {
export class TimelineListVirtualDelegate implements IListVirtualDelegate<TreeElement> {
getHeight(_element: TreeElement): number {
return 22;
}
getTemplateId(element: TimelineItem): string {
getTemplateId(element: TreeElement): string {
return TimelineElementTemplate.id;
}
}
......@@ -353,14 +447,25 @@ export class TimelineListVirtualDelegate implements IListVirtualDelegate<Timelin
class TimelineTreeRenderer implements ITreeRenderer<TreeElement, FuzzyScore, TimelineElementTemplate> {
readonly templateId: string = TimelineElementTemplate.id;
constructor(@IThemeService private _themeService: IThemeService) { }
private _actionViewItemProvider: IActionViewItemProvider;
renderTemplate(container: HTMLElement): TimelineElementTemplate {
DOM.addClass(container, 'custom-view-tree-node-item');
const icon = DOM.append(container, DOM.$('.custom-view-tree-node-item-icon'));
constructor(
private readonly _menus: TimelineMenus,
@IInstantiationService protected readonly instantiationService: IInstantiationService,
@IThemeService private _themeService: IThemeService
) {
this._actionViewItemProvider = (action: IAction) => action instanceof MenuItemAction
? this.instantiationService.createInstance(ContextAwareMenuEntryActionViewItem, action)
: undefined;
}
private _uri: URI | undefined;
setUri(uri: URI | undefined) {
this._uri = uri;
}
const iconLabel = new IconLabel(container, { supportHighlights: true, supportCodicons: true });
return new TimelineElementTemplate(container, iconLabel, icon);
renderTemplate(container: HTMLElement): TimelineElementTemplate {
return new TimelineElementTemplate(container, this._actionViewItemProvider);
}
renderElement(
......@@ -369,30 +474,74 @@ class TimelineTreeRenderer implements ITreeRenderer<TreeElement, FuzzyScore, Tim
template: TimelineElementTemplate,
height: number | undefined
): void {
const { element } = node;
template.reset();
const icon = this._themeService.getTheme().type === LIGHT ? element.icon : element.iconDark;
const { element: item } = node;
const icon = this._themeService.getTheme().type === LIGHT ? item.icon : item.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);
if (item.themeIcon /*&& !this.isFileKindThemeIcon(element.themeIcon)*/) {
iconClass = ThemeIcon.asClassName(item.themeIcon);
}
template.icon.className = iconClass ? `custom-view-tree-node-item-icon ${iconClass}` : '';
}
template.iconLabel.setLabel(element.label, element.description, {
title: element.detail,
template.iconLabel.setLabel(item.label, item.description, {
title: item.detail,
matches: createMatches(node.filterData)
});
template.timestamp.textContent = fromNow(item.timestamp);
template.actionBar.context = { uri: this._uri, item: item } as TimelineActionContext;
template.actionBar.actionRunner = new TimelineActionRunner();
template.actionBar.push(this._menus.getResourceActions(item), { icon: true, label: false });
}
disposeTemplate(template: TimelineElementTemplate): void {
template.iconLabel.dispose();
}
}
class TimelineMenus extends Disposable {
constructor(
private id: string,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IMenuService private readonly menuService: IMenuService,
@IContextMenuService private readonly contextMenuService: IContextMenuService
) {
super();
}
getResourceActions(element: TreeElement): IAction[] {
return this.getActions(MenuId.TimelineItemContext, { key: 'timelineItem', value: element.contextValue }).primary;
}
getResourceContextActions(element: TreeElement): IAction[] {
return this.getActions(MenuId.TimelineItemContext, { key: 'timelineItem', value: element.contextValue }).secondary;
}
private getActions(menuId: MenuId, context: { key: string, value?: string }): { primary: IAction[]; secondary: IAction[]; } {
const contextKeyService = this.contextKeyService.createScoped();
contextKeyService.createKey('view', this.id);
contextKeyService.createKey(context.key, context.value);
const menu = this.menuService.createMenu(menuId, contextKeyService);
const primary: IAction[] = [];
const secondary: IAction[] = [];
const result = { primary, secondary };
createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, result, this.contextMenuService, g => /^inline/.test(g));
menu.dispose();
contextKeyService.dispose();
return result;
}
}
......@@ -16,9 +16,11 @@ export function toKey(extension: ExtensionIdentifier | string, source: string) {
}
export interface TimelineItem {
handle: string;
source: string;
timestamp: number;
label: string;
id?: string;
icon?: URI,
iconDark?: URI,
themeIcon?: { id: string },
......@@ -28,10 +30,6 @@ export interface TimelineItem {
contextValue?: string;
}
export interface TimelineItemWithSource extends TimelineItem {
source: string;
}
export interface TimelineChangeEvent {
id: string;
uri?: URI;
......@@ -45,7 +43,7 @@ export interface TimelineCursor {
export interface Timeline {
source: string;
items: TimelineItemWithSource[];
items: TimelineItem[];
cursor?: any;
more?: boolean;
......@@ -54,7 +52,7 @@ export interface Timeline {
export interface TimelineProvider extends TimelineProviderDescriptor, IDisposable {
onDidChange?: Event<TimelineChangeEvent>;
provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken): Promise<Timeline | undefined>;
provideTimeline(uri: URI, cursor: TimelineCursor, token: CancellationToken, options?: { cacheResults?: boolean }): Promise<Timeline | undefined>;
}
export interface TimelineProviderDescriptor {
......@@ -86,7 +84,7 @@ export interface ITimelineService {
getSources(): string[];
getTimeline(id: string, uri: URI, pagination: TimelineCursor, tokenSource: CancellationTokenSource): TimelineRequest | undefined;
getTimeline(id: string, uri: URI, cursor: TimelineCursor, tokenSource: CancellationTokenSource, options?: { cacheResults?: boolean }): TimelineRequest | undefined;
}
const TIMELINE_SERVICE_ID = 'timeline';
......
......@@ -81,7 +81,7 @@ export class TimelineService implements ITimelineService {
return [...this._providers.keys()];
}
getTimeline(id: string, uri: URI, cursor: TimelineCursor, tokenSource: CancellationTokenSource) {
getTimeline(id: string, uri: URI, cursor: TimelineCursor, tokenSource: CancellationTokenSource, options?: { cacheResults?: boolean }) {
this.logService.trace(`TimelineService#getTimeline(${id}): uri=${uri.toString(true)}`);
const provider = this._providers.get(id);
......@@ -98,7 +98,7 @@ export class TimelineService implements ITimelineService {
}
return {
result: provider.provideTimeline(uri, cursor, tokenSource.token)
result: provider.provideTimeline(uri, cursor, tokenSource.token, options)
.then(result => {
if (result === undefined) {
return undefined;
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册