timelinePane.ts 35.1 KB
Newer Older
1 2 3 4 5 6
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import 'vs/css!./media/timelinePane';
E
Eric Amodio 已提交
7
import { localize } from 'vs/nls';
8
import * as DOM from 'vs/base/browser/dom';
9 10
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { FuzzyScore, createMatches } from 'vs/base/common/filters';
11
import { DisposableStore, IDisposable, Disposable } from 'vs/base/common/lifecycle';
12 13 14
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';
15
import { ITreeNode, ITreeRenderer, ITreeContextMenuEvent, ITreeElement } from 'vs/base/browser/ui/tree/tree';
16
import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPaneContainer';
17
import { ResourceNavigator, WorkbenchObjectTree } from 'vs/platform/list/browser/listService';
18 19
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
20
import { ContextKeyExpr, IContextKeyService, RawContextKey, IContextKey } from 'vs/platform/contextkey/common/contextkey';
21
import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration';
22
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
23
import { ITimelineService, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvidersChangeEvent, TimelineRequest, Timeline } from 'vs/workbench/contrib/timeline/common/timeline';
24 25
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { SideBySideEditor, toResource } from 'vs/workbench/common/editor';
26
import { ICommandService, CommandsRegistry } from 'vs/platform/commands/common/commands';
27
import { IThemeService, LIGHT, ThemeIcon } from 'vs/platform/theme/common/themeService';
28
import { IViewDescriptorService } from 'vs/workbench/common/views';
E
Eric Amodio 已提交
29 30
import { basename } from 'vs/base/common/path';
import { IProgressService } from 'vs/platform/progress/common/progress';
31
import { debounce } from 'vs/base/common/decorators';
32
import { IOpenerService } from 'vs/platform/opener/common/opener';
33 34
import { IActionViewItemProvider, ActionBar, ActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar';
import { IAction, ActionRunner } from 'vs/base/common/actions';
35
import { ContextAwareMenuEntryActionViewItem, createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
36
import { MenuItemAction, IMenuService, MenuId, registerAction2, Action2, MenuRegistry } from 'vs/platform/actions/common/actions';
37
import { fromNow } from 'vs/base/common/date';
38
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
39
import { escapeRegExpCharacters } from 'vs/base/common/strings';
40
import { Iterable } from 'vs/base/common/iterator';
41

42
const PageSize = 20;
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75

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

76 77 78 79
interface TimelineActionContext {
	uri: URI | undefined;
	item: TreeElement;
}
E
Eric Amodio 已提交
80

81 82
class TimelineAggregate {
	readonly items: TimelineItem[];
83
	readonly source: string;
84 85 86 87

	lastRenderedIndex: number;

	constructor(timeline: Timeline) {
88
		this.source = timeline.source;
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
		this.items = timeline.items;
		this._cursor = timeline.paging?.cursor;
		this._more = timeline.paging?.more ?? false;
		this.lastRenderedIndex = -1;
	}

	private _cursor?: string;
	get cursor(): string | undefined {
		return this._cursor;
	}

	private _more: boolean;
	get more(): boolean {
		return this._more;
	}

	get newest(): TimelineItem | undefined {
		return this.items[0];
	}

	get oldest(): TimelineItem | undefined {
		return this.items[this.items.length - 1];
	}

	add(timeline: Timeline) {
		let updated = false;

		if (timeline.items.length !== 0 && this.items.length !== 0) {
			updated = true;

			const ids = new Set();
			const timestamps = new Set();

			for (const item of timeline.items) {
				if (item.id === undefined) {
					timestamps.add(item.timestamp);
				}
				else {
					ids.add(item.id);
				}
			}

			// Remove any duplicate items
			let i = this.items.length;
			let item;
			while (i--) {
				item = this.items[i];
				if ((item.id !== undefined && ids.has(item.id)) || timestamps.has(item.timestamp)) {
					this.items.splice(i, 1);
				}
			}

			if ((timeline.items[timeline.items.length - 1]?.timestamp ?? 0) >= (this.newest?.timestamp ?? 0)) {
				this.items.splice(0, 0, ...timeline.items);
			} else {
				this.items.push(...timeline.items);
			}
		} else if (timeline.items.length !== 0) {
			updated = true;

			this.items.push(...timeline.items);
		}

		this._cursor = timeline.paging?.cursor;
		this._more = timeline.paging?.more ?? false;

		if (updated) {
			this.items.sort(
				(a, b) =>
					(b.timestamp - a.timestamp) ||
					(a.source === undefined
						? b.source === undefined ? 0 : 1
						: b.source === undefined ? -1 : b.source.localeCompare(a.source, undefined, { numeric: true, sensitivity: 'base' }))
			);
		}

		return updated;
	}
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181

	private _stale = false;
	get stale() {
		return this._stale;
	}

	private _requiresReset = false;
	get requiresReset(): boolean {
		return this._requiresReset;
	}

	invalidate(requiresReset: boolean) {
		this._stale = true;
		this._requiresReset = requiresReset;
	}
182 183
}

184 185
export const TimelineFollowActiveEditorContext = new RawContextKey<boolean>('timelineFollowActiveEditor', true);

186
export class TimelinePane extends ViewPane {
187
	static readonly TITLE = localize('timeline', "Timeline");
E
Eric Amodio 已提交
188

189 190 191 192 193 194
	private $container!: HTMLElement;
	private $message!: HTMLDivElement;
	private $titleDescription!: HTMLSpanElement;
	private $tree!: HTMLDivElement;
	private tree!: WorkbenchObjectTree<TreeElement, FuzzyScore>;
	private treeRenderer: TimelineTreeRenderer | undefined;
195
	private commands: TimelinePaneCommands;
196 197 198
	private visibilityDisposables: DisposableStore | undefined;

	private followActiveEditorContext: IContextKey<boolean>;
199

200 201 202
	private excludedSources: Set<string>;
	private pendingRequests = new Map<string, TimelineRequest>();
	private timelinesBySource = new Map<string, TimelineAggregate>();
203

204
	private uri: URI | undefined;
205

206 207 208 209 210 211 212 213 214
	constructor(
		options: IViewPaneOptions,
		@IKeybindingService protected keybindingService: IKeybindingService,
		@IContextMenuService protected contextMenuService: IContextMenuService,
		@IContextKeyService protected contextKeyService: IContextKeyService,
		@IConfigurationService protected configurationService: IConfigurationService,
		@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
		@IInstantiationService protected readonly instantiationService: IInstantiationService,
		@IEditorService protected editorService: IEditorService,
215
		@ICommandService protected commandService: ICommandService,
E
Eric Amodio 已提交
216
		@IProgressService private readonly progressService: IProgressService,
217 218 219
		@ITimelineService protected timelineService: ITimelineService,
		@IOpenerService openerService: IOpenerService,
		@IThemeService themeService: IThemeService,
220
		@ITelemetryService telemetryService: ITelemetryService,
221
	) {
222
		super({ ...options, titleMenuId: MenuId.TimelineTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService);
223

224
		this.commands = this._register(this.instantiationService.createInstance(TimelinePaneCommands, this));
225

226
		this.followActiveEditorContext = TimelineFollowActiveEditorContext.bindTo(this.contextKeyService);
227

228
		this.excludedSources = new Set(configurationService.getValue('timeline.excludeSources'));
229
		configurationService.onDidChangeConfiguration(this.onConfigurationChanged, this);
230

231 232
		this._register(timelineService.onDidChangeProviders(this.onProvidersChanged, this));
		this._register(timelineService.onDidChangeTimeline(this.onTimelineChanged, this));
233 234 235 236 237 238 239 240 241 242 243 244 245
		this._register(timelineService.onDidChangeUri(uri => this.setUri(uri), this));
	}

	private _followActiveEditor: boolean = true;
	get followActiveEditor(): boolean {
		return this._followActiveEditor;
	}
	set followActiveEditor(value: boolean) {
		if (this._followActiveEditor === value) {
			return;
		}

		this._followActiveEditor = value;
246
		this.followActiveEditorContext.set(value);
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265

		if (value) {
			this.onActiveEditorChanged();
		}
	}

	reset() {
		this.loadTimeline(true);
	}

	setUri(uri: URI) {
		this.setUriCore(uri, true);
	}

	private setUriCore(uri: URI | undefined, disableFollowing: boolean) {
		if (disableFollowing) {
			this.followActiveEditor = false;
		}

266
		this.uri = uri;
267
		this.titleDescription = uri ? basename(uri.fsPath) : '';
268
		this.treeRenderer?.setUri(uri);
269
		this.loadTimeline(true);
270 271 272 273 274 275 276
	}

	private onConfigurationChanged(e: IConfigurationChangeEvent) {
		if (!e.affectsConfiguration('timeline.excludeSources')) {
			return;
		}

277 278 279 280 281 282 283 284 285
		this.excludedSources = new Set(this.configurationService.getValue('timeline.excludeSources'));

		const missing = this.timelineService.getSources()
			.filter(({ id }) => !this.excludedSources.has(id) && !this.timelinesBySource.has(id));
		if (missing.length !== 0) {
			this.loadTimeline(true, missing.map(({ id }) => id));
		} else {
			this.refresh();
		}
286 287
	}

288
	private onActiveEditorChanged() {
289 290 291 292
		if (!this.followActiveEditor) {
			return;
		}

293 294 295 296 297 298 299
		let uri;

		const editor = this.editorService.activeEditor;
		if (editor) {
			uri = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER });
		}

300
		if ((uri?.toString(true) === this.uri?.toString(true) && uri !== undefined) ||
301
			// Fallback to match on fsPath if we are dealing with files or git schemes
302
			(uri?.fsPath === this.uri?.fsPath && (uri?.scheme === 'file' || uri?.scheme === 'git') && (this.uri?.scheme === 'file' || this.uri?.scheme === 'git'))) {
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321

			// If the uri hasn't changed, make sure we have valid caches
			for (const source of this.timelineService.getSources()) {
				if (this.excludedSources.has(source.id)) {
					continue;
				}

				const timeline = this.timelinesBySource.get(source.id);
				if (timeline !== undefined && !timeline.stale) {
					continue;
				}

				if (timeline !== undefined) {
					this.updateTimeline(timeline, timeline.requiresReset);
				} else {
					this.loadTimelineForSource(source.id, uri, true);
				}
			}

322 323 324
			return;
		}

325
		this.setUriCore(uri, false);
326 327
	}

328 329 330
	private onProvidersChanged(e: TimelineProvidersChangeEvent) {
		if (e.removed) {
			for (const source of e.removed) {
331
				this.timelinesBySource.delete(source);
332
			}
333 334

			this.refresh();
335 336 337
		}

		if (e.added) {
338
			this.loadTimeline(true, e.added);
339
		}
340 341
	}

342
	private onTimelineChanged(e: TimelineChangeEvent) {
343 344 345 346 347 348
		if (e?.uri === undefined || e.uri.toString(true) !== this.uri?.toString(true)) {
			const timeline = this.timelinesBySource.get(e.id);
			if (timeline === undefined) {
				return;
			}

349 350
			if (this.isBodyVisible()) {
				this.updateTimeline(timeline, e.reset ?? false);
351
			} else {
352
				timeline.invalidate(e.reset ?? false);
353
			}
354 355 356
		}
	}

357 358 359 360 361 362 363
	private _titleDescription: string | undefined;
	get titleDescription(): string | undefined {
		return this._titleDescription;
	}

	set titleDescription(description: string | undefined) {
		this._titleDescription = description;
364
		this.$titleDescription.textContent = description ?? '';
365 366
	}

E
Eric Amodio 已提交
367 368 369 370 371 372 373 374 375 376 377
	private _message: string | undefined;
	get message(): string | undefined {
		return this._message;
	}

	set message(message: string | undefined) {
		this._message = message;
		this.updateMessage();
	}

	private updateMessage(): void {
378
		if (this._message !== undefined) {
E
Eric Amodio 已提交
379 380 381 382 383 384 385
			this.showMessage(this._message);
		} else {
			this.hideMessage();
		}
	}

	private showMessage(message: string): void {
386
		DOM.removeClass(this.$message, 'hide');
E
Eric Amodio 已提交
387 388
		this.resetMessageElement();

389
		this.$message.textContent = message;
E
Eric Amodio 已提交
390 391 392 393
	}

	private hideMessage(): void {
		this.resetMessageElement();
394
		DOM.addClass(this.$message, 'hide');
E
Eric Amodio 已提交
395 396 397
	}

	private resetMessageElement(): void {
398
		DOM.clearNode(this.$message);
E
Eric Amodio 已提交
399 400
	}

401 402 403 404 405 406 407
	private _isEmpty = true;
	private _maxItemCount = 0;

	private _visibleItemCount = 0;
	private get hasVisibleItems() {
		return this._visibleItemCount > 0;
	}
408

409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427
	private clear(cancelPending: boolean) {
		this._visibleItemCount = 0;
		this._maxItemCount = PageSize;
		this.timelinesBySource.clear();

		if (cancelPending) {
			for (const { tokenSource } of this.pendingRequests.values()) {
				tokenSource.dispose(true);
			}

			this.pendingRequests.clear();

			if (!this.isBodyVisible()) {
				this.tree.setChildren(null, undefined);
				this._isEmpty = true;
			}
		}
	}

428
	private async loadTimeline(reset: boolean, sources?: string[]) {
429 430
		// If we have no source, we are reseting all sources, so cancel everything in flight and reset caches
		if (sources === undefined) {
431
			if (reset) {
432
				this.clear(true);
433
			}
E
Eric Amodio 已提交
434

435 436 437 438
			// TODO@eamodio: Are these the right the list of schemes to exclude? Is there a better way?
			if (this.uri?.scheme === 'vscode-settings' || this.uri?.scheme === 'webview-panel' || this.uri?.scheme === 'walkThrough') {
				this.uri = undefined;

439
				this.clear(false);
440
				this.refresh();
441 442 443

				return;
			}
444

445
			if (this._isEmpty && this.uri !== undefined) {
446
				this.setLoadingUriMessage();
E
Eric Amodio 已提交
447
			}
448 449
		}

450
		if (this.uri === undefined) {
451
			this.clear(false);
452 453
			this.refresh();

454 455 456
			return;
		}

457 458 459 460
		if (!this.isBodyVisible()) {
			return;
		}

461
		let hasPendingRequests = false;
462

463 464 465 466 467
		for (const source of sources ?? this.timelineService.getSources().map(s => s.id)) {
			const requested = this.loadTimelineForSource(source, this.uri, reset);
			if (requested) {
				hasPendingRequests = true;
			}
468 469
		}

470 471 472 473
		if (!hasPendingRequests) {
			this.refresh();
		} else if (this._isEmpty) {
			this.setLoadingUriMessage();
474
		}
475
	}
476

477 478 479 480
	private loadTimelineForSource(source: string, uri: URI, reset: boolean, options?: TimelineOptions) {
		if (this.excludedSources.has(source)) {
			return false;
		}
481

482
		const timeline = this.timelinesBySource.get(source);
483

484 485 486 487 488 489 490 491 492
		// If we are paging, and there are no more items or we have enough cached items to cover the next page,
		// don't bother querying for more
		if (
			!reset &&
			timeline !== undefined &&
			(!timeline?.more || timeline.items.length > timeline.lastRenderedIndex + PageSize)
		) {
			return false;
		}
493

494 495 496
		if (options === undefined) {
			options = { cursor: reset ? undefined : timeline?.cursor, limit: PageSize };
		}
497

498 499 500
		let request = this.pendingRequests.get(source);
		if (request !== undefined) {
			options.cursor = request.options.cursor;
E
Eric Amodio 已提交
501

502 503 504 505 506 507
			// TODO@eamodio deal with concurrent requests better
			if (typeof options.limit === 'number') {
				if (typeof request.options.limit === 'number') {
					options.limit += request.options.limit;
				} else {
					options.limit = request.options.limit;
E
Eric Amodio 已提交
508
				}
509
			}
510
		}
511
		request?.tokenSource.dispose(true);
512

513 514 515 516 517 518
		request = this.timelineService.getTimeline(
			source, uri, options, new CancellationTokenSource(), { cacheResults: true, resetCache: reset }
		);

		if (request === undefined) {
			return false;
519
		}
520 521 522 523 524 525 526

		this.pendingRequests.set(source, request);
		request.tokenSource.token.onCancellationRequested(() => this.pendingRequests.delete(source));

		this.handleRequest(request);

		return true;
527 528
	}

529 530 531 532 533 534 535 536 537 538 539 540 541
	private updateTimeline(timeline: TimelineAggregate, reset: boolean) {
		if (reset) {
			this.timelinesBySource.delete(timeline.source);
			// Override the limit, to re-query for all our existing cached (possibly visible) items to keep visual continuity
			const { oldest } = timeline;
			this.loadTimelineForSource(timeline.source, this.uri!, true, oldest !== undefined ? { limit: { timestamp: oldest.timestamp, id: oldest.id } } : undefined);
		} else {
			// Override the limit, to query for any newer items
			const { newest } = timeline;
			this.loadTimelineForSource(timeline.source, this.uri!, false, newest !== undefined ? { limit: { timestamp: newest.timestamp, id: newest.id } } : { limit: PageSize });
		}
	}

542 543
	private _pendingRefresh = false;

544
	private async handleRequest(request: TimelineRequest) {
545
		let response: Timeline | undefined;
546
		try {
547
			response = await this.progressService.withProgress({ location: this.id }, () => request.result);
548 549
		}
		finally {
550
			this.pendingRequests.delete(request.source);
551
		}
552

553
		if (
554
			response === undefined ||
555
			request.tokenSource.token.isCancellationRequested ||
556
			request.uri !== this.uri
557
		) {
558
			if (this.pendingRequests.size === 0 && this._pendingRefresh) {
559 560 561 562 563 564
				this.refresh();
			}

			return;
		}

565 566
		const source = request.source;

567 568 569 570 571
		let updated = false;
		const timeline = this.timelinesBySource.get(source);
		if (timeline === undefined) {
			this.timelinesBySource.set(source, new TimelineAggregate(response));
			updated = true;
572
		}
573 574
		else {
			updated = timeline.add(response);
575
		}
576

577 578 579 580 581 582 583
		if (updated) {
			this._pendingRefresh = true;

			// If we have visible items already and there are other pending requests, debounce for a bit to wait for other requests
			if (this.hasVisibleItems && this.pendingRequests.size !== 0) {
				this.refreshDebounced();
			} else {
584 585
				this.refresh();
			}
586 587 588 589 590 591 592 593 594 595
		} else if (this.pendingRequests.size === 0 && this._pendingRefresh) {
			this.refresh();
		}
	}

	private *getItems(): Generator<ITreeElement<TreeElement>, any, any> {
		let more = false;

		if (this.uri === undefined || this.timelinesBySource.size === 0) {
			this._visibleItemCount = 0;
596

E
Eric Amodio 已提交
597 598 599
			return;
		}

600 601
		const maxCount = this._maxItemCount;
		let count = 0;
602

603 604 605 606 607 608 609 610 611 612 613 614 615 616
		if (this.timelinesBySource.size === 1) {
			const [source, timeline] = Iterable.first(this.timelinesBySource);

			timeline.lastRenderedIndex = -1;

			if (this.excludedSources.has(source)) {
				this._visibleItemCount = 0;

				return;
			}

			if (timeline.items.length !== 0) {
				// If we have any items, just say we have one for now -- the real count will be updated below
				this._visibleItemCount = 1;
617
			}
618 619 620 621 622 623 624 625

			more = timeline.more;

			for (const item of timeline.items) {
				count++;
				if (count > maxCount) {
					more = true;
					break;
626
				}
627 628

				yield { element: item };
629 630
			}

631
			timeline.lastRenderedIndex = count - 1;
632
		}
633 634
		else {
			const sources: { timeline: TimelineAggregate; iterator: IterableIterator<TimelineItem>; nextItem: IteratorResult<TimelineItem, TimelineItem> }[] = [];
635

636 637
			let hasAnyItems = false;
			let mostRecentEnd = 0;
638

639 640
			for (const [source, timeline] of this.timelinesBySource) {
				timeline.lastRenderedIndex = -1;
641

642
				if (this.excludedSources.has(source) || timeline.stale) {
643
					continue;
644
				}
645 646 647

				if (timeline.items.length !== 0) {
					hasAnyItems = true;
648 649
				}

650 651 652 653 654 655 656
				if (timeline.more) {
					more = true;

					const last = timeline.items[Math.min(maxCount, timeline.items.length - 1)];
					if (last.timestamp > mostRecentEnd) {
						mostRecentEnd = last.timestamp;
					}
657
				}
658 659 660

				const iterator = timeline.items[Symbol.iterator]();
				sources.push({ timeline: timeline, iterator: iterator, nextItem: iterator.next() });
661 662
			}

663
			this._visibleItemCount = hasAnyItems ? 1 : 0;
664

665 666 667 668 669
			function getNextMostRecentSource() {
				return sources
					.filter(source => !source.nextItem!.done)
					.reduce((previous, current) => (previous === undefined || current.nextItem!.value.timestamp >= previous.nextItem!.value.timestamp) ? current : previous, undefined!);
			}
670

671 672 673
			let nextSource;
			while (nextSource = getNextMostRecentSource()) {
				nextSource.timeline.lastRenderedIndex++;
674

675 676 677 678 679 680
				if (nextSource.nextItem.value.timestamp >= mostRecentEnd) {
					count++;
					if (count > maxCount) {
						more = true;
						break;
					}
681

682 683
					yield { element: nextSource.nextItem.value };
				}
684

685 686
				nextSource.nextItem = nextSource.iterator.next();
			}
687 688
		}

689
		this._visibleItemCount = count;
690

691 692 693 694 695 696 697 698 699
		if (more) {
			yield {
				element: {
					handle: 'vscode-command:loadMore',
					label: localize('timeline.loadMore', 'Load more'),
					timestamp: 0
				} as CommandItem
			};
		}
700 701
	}

702
	private refresh() {
703 704 705 706
		if (!this.isBodyVisible()) {
			return;
		}

707 708 709 710
		this.tree.setChildren(null, this.getItems() as any);
		this._isEmpty = !this.hasVisibleItems;

		if (this.uri === undefined) {
711 712
			this.titleDescription = undefined;
			this.message = localize('timeline.editorCannotProvideTimeline', 'The active editor cannot provide timeline information.');
713 714
		} else if (this._isEmpty) {
			if (this.pendingRequests.size !== 0) {
715
				this.setLoadingUriMessage();
716
			} else {
717
				this.titleDescription = basename(this.uri.fsPath);
718
				this.message = localize('timeline.noTimelineInfo', 'No timeline information was provided.');
719
			}
720
		} else {
721
			this.titleDescription = basename(this.uri.fsPath);
722
			this.message = undefined;
723 724
		}

725
		this._pendingRefresh = false;
726 727 728 729
	}

	@debounce(500)
	private refreshDebounced() {
730 731 732 733 734
		this.refresh();
	}

	focus(): void {
		super.focus();
735
		this.tree.domFocus();
736 737
	}

738 739 740 741 742 743 744 745 746 747
	setExpanded(expanded: boolean): boolean {
		const changed = super.setExpanded(expanded);

		if (changed && this.isBodyVisible()) {
			this.onActiveEditorChanged();
		}

		return changed;
	}

748 749
	setVisible(visible: boolean): void {
		if (visible) {
750
			this.visibilityDisposables = new DisposableStore();
751

752
			this.editorService.onDidActiveEditorChange(this.onActiveEditorChanged, this, this.visibilityDisposables);
753

754 755
			this.onActiveEditorChanged();
		} else {
756
			this.visibilityDisposables?.dispose();
757
		}
758 759

		super.setVisible(visible);
760 761 762
	}

	protected layoutBody(height: number, width: number): void {
763
		this.tree.layout(height, width);
764 765
	}

766 767 768 769
	protected renderHeaderTitle(container: HTMLElement): void {
		super.renderHeaderTitle(container, this.title);

		DOM.addClass(container, 'timeline-view');
770
		this.$titleDescription = DOM.append(container, DOM.$('span.description', undefined, this.titleDescription ?? ''));
771 772
	}

773
	protected renderBody(container: HTMLElement): void {
774 775
		super.renderBody(container);

776
		this.$container = container;
777
		DOM.addClasses(container, 'tree-explorer-viewlet-tree-view', 'timeline-tree-view');
E
Eric Amodio 已提交
778

779 780
		this.$message = DOM.append(this.$container, DOM.$('.message'));
		DOM.addClass(this.$message, 'timeline-subtle');
E
Eric Amodio 已提交
781

E
Eric Amodio 已提交
782
		this.message = localize('timeline.editorCannotProvideTimeline', 'The active editor cannot provide timeline information.');
E
Eric Amodio 已提交
783

784 785 786 787
		this.$tree = document.createElement('div');
		DOM.addClasses(this.$tree, 'customview-tree', 'file-icon-themable-tree', 'hide-arrows');
		// DOM.addClass(this.treeElement, 'show-file-icons');
		container.appendChild(this.$tree);
788

789 790 791
		this.treeRenderer = this.instantiationService.createInstance(TimelineTreeRenderer, this.commands);
		this.tree = <WorkbenchObjectTree<TreeElement, FuzzyScore>>this.instantiationService.createInstance(WorkbenchObjectTree, 'TimelinePane',
			this.$tree, new TimelineListVirtualDelegate(), [this.treeRenderer], {
792
			identityProvider: new TimelineIdentityProvider(),
S
SteVen Batten 已提交
793 794
			keyboardNavigationLabelProvider: new TimelineKeyboardNavigationLabelProvider(),
			overrideStyles: {
795 796
				listBackground: this.getBackgroundColor(),

S
SteVen Batten 已提交
797
			}
798 799
		});

800
		const customTreeNavigator = ResourceNavigator.createTreeResourceNavigator(this.tree, { openOnFocus: false, openOnSelection: false });
801
		this._register(customTreeNavigator);
802 803
		this._register(this.tree.onContextMenu(e => this.onContextMenu(this.commands, e)));
		this._register(this.tree.onDidChangeSelection(e => this.ensureValidItems()));
804 805
		this._register(
			customTreeNavigator.onDidOpenResource(e => {
806
				if (!e.browserEvent || !this.ensureValidItems()) {
807 808 809
					return;
				}

810
				const selection = this.tree.getSelection();
811 812 813 814 815 816 817 818 819 820 821 822
				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)) {
823
					if (this.pendingRequests.size !== 0) {
824 825 826
						return;
					}

827
					this._maxItemCount = this._visibleItemCount + PageSize;
828
					this.loadTimeline(false);
829 830
				}
			})
831 832
		);
	}
833
	ensureValidItems() {
834 835 836 837
		// If we don't have any non-excluded timelines, clear the tree and show the loading message
		if (!this.hasVisibleItems || !this.timelineService.getSources().some(({ id }) => !this.excludedSources.has(id) && this.timelinesBySource.has(id))) {
			this.tree.setChildren(null, undefined);
			this._isEmpty = true;
838 839 840 841 842 843 844 845 846 847

			this.setLoadingUriMessage();

			return false;
		}

		return true;
	}

	setLoadingUriMessage() {
848
		const file = this.uri && basename(this.uri.fsPath);
849 850 851
		this.titleDescription = file ?? '';
		this.message = file ? localize('timeline.loading', 'Loading timeline for {0}...', file) : '';
	}
852

853
	private onContextMenu(commands: TimelinePaneCommands, treeEvent: ITreeContextMenuEvent<TreeElement | null>): void {
854 855 856 857 858 859 860 861 862
		const item = treeEvent.element;
		if (item === null) {
			return;
		}
		const event: UIEvent = treeEvent.browserEvent;

		event.preventDefault();
		event.stopPropagation();

863 864 865 866
		if (!this.ensureValidItems()) {
			return;
		}

867
		this.tree.setFocus([item]);
868
		const actions = commands.getItemContextActions(item);
869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884
		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) {
885
					this.tree.domFocus();
886 887
				}
			},
888
			getActionsContext: (): TimelineActionContext => ({ uri: this.uri, item: item }),
889 890 891
			actionRunner: new TimelineActionRunner()
		});
	}
892 893
}

894
export class TimelineElementTemplate implements IDisposable {
895 896
	static readonly id = 'TimelineElementTemplate';

897 898 899 900 901
	readonly actionBar: ActionBar;
	readonly icon: HTMLElement;
	readonly iconLabel: IconLabel;
	readonly timestamp: HTMLSpanElement;

902 903
	constructor(
		readonly container: HTMLElement,
904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931
		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;
	}
932 933
}

934 935 936
class TimelineActionRunner extends ActionRunner {

	runAction(action: IAction, { uri, item }: TimelineActionContext): Promise<any> {
937
		if (!isTimelineItem(item)) {
938
			// TODO@eamodio do we need to do anything else?
939 940 941
			return action.run();
		}

942 943 944 945 946 947 948 949 950 951
		return action.run(...[
			{
				$mid: 11,
				handle: item.handle,
				source: item.source,
				uri: uri
			},
			uri,
			item.source,
		]);
952 953 954
	}
}

955 956
export class TimelineKeyboardNavigationLabelProvider implements IKeyboardNavigationLabelProvider<TreeElement> {
	getKeyboardNavigationLabel(element: TreeElement): { toString(): string } {
957 958 959 960
		return element.label;
	}
}

961 962
export class TimelineListVirtualDelegate implements IListVirtualDelegate<TreeElement> {
	getHeight(_element: TreeElement): number {
963 964 965
		return 22;
	}

966
	getTemplateId(element: TreeElement): string {
967 968 969 970 971 972 973
		return TimelineElementTemplate.id;
	}
}

class TimelineTreeRenderer implements ITreeRenderer<TreeElement, FuzzyScore, TimelineElementTemplate> {
	readonly templateId: string = TimelineElementTemplate.id;

974
	private actionViewItemProvider: IActionViewItemProvider;
975

976
	constructor(
977
		private readonly commands: TimelinePaneCommands,
978
		@IInstantiationService protected readonly instantiationService: IInstantiationService,
979
		@IThemeService private themeService: IThemeService
980
	) {
981
		this.actionViewItemProvider = (action: IAction) => action instanceof MenuItemAction
982 983 984 985
			? this.instantiationService.createInstance(ContextAwareMenuEntryActionViewItem, action)
			: undefined;
	}

986
	private uri: URI | undefined;
987
	setUri(uri: URI | undefined) {
988
		this.uri = uri;
989
	}
990

991
	renderTemplate(container: HTMLElement): TimelineElementTemplate {
992
		return new TimelineElementTemplate(container, this.actionViewItemProvider);
993 994
	}

995 996 997 998 999 1000
	renderElement(
		node: ITreeNode<TreeElement, FuzzyScore>,
		index: number,
		template: TimelineElementTemplate,
		height: number | undefined
	): void {
1001
		template.reset();
1002

1003 1004
		const { element: item } = node;

1005
		const icon = this.themeService.getColorTheme().type === LIGHT ? item.icon : item.iconDark;
1006 1007 1008 1009 1010 1011 1012
		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;
1013 1014
			if (item.themeIcon /*&& !this.isFileKindThemeIcon(element.themeIcon)*/) {
				iconClass = ThemeIcon.asClassName(item.themeIcon);
1015 1016 1017 1018
			}
			template.icon.className = iconClass ? `custom-view-tree-node-item-icon ${iconClass}` : '';
		}

1019 1020
		template.iconLabel.setLabel(item.label, item.description, {
			title: item.detail,
1021 1022
			matches: createMatches(node.filterData)
		});
1023

1024
		template.timestamp.textContent = isTimelineItem(item) ? fromNow(item.timestamp) : '';
1025

1026
		template.actionBar.context = { uri: this.uri, item: item } as TimelineActionContext;
1027
		template.actionBar.actionRunner = new TimelineActionRunner();
1028
		template.actionBar.push(this.commands.getItemActions(item), { icon: true, label: false });
1029 1030 1031 1032 1033 1034
	}

	disposeTemplate(template: TimelineElementTemplate): void {
		template.iconLabel.dispose();
	}
}
1035

1036 1037
class TimelinePaneCommands extends Disposable {
	private sourceDisposables: DisposableStore;
1038 1039

	constructor(
1040 1041 1042
		private readonly pane: TimelinePane,
		@ITimelineService private readonly timelineService: ITimelineService,
		@IConfigurationService private readonly configurationService: IConfigurationService,
1043 1044 1045 1046 1047
		@IContextKeyService private readonly contextKeyService: IContextKeyService,
		@IMenuService private readonly menuService: IMenuService,
		@IContextMenuService private readonly contextMenuService: IContextMenuService
	) {
		super();
1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069

		this._register(this.sourceDisposables = new DisposableStore());

		this._register(registerAction2(class extends Action2 {
			constructor() {
				super({
					id: 'timeline.refresh',
					title: { value: localize('refresh', "Refresh"), original: 'Refresh' },
					icon: { id: 'codicon/refresh' },
					category: { value: localize('timeline', "Timeline"), original: 'Timeline' },
					menu: {
						id: MenuId.TimelineTitle,
						group: 'navigation',
						order: 99,
					}
				});
			}
			run(accessor: ServicesAccessor, ...args: any[]) {
				pane.reset();
			}
		}));

1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098
		this._register(CommandsRegistry.registerCommand('timeline.toggleFollowActiveEditor',
			(accessor: ServicesAccessor, ...args: any[]) => pane.followActiveEditor = !pane.followActiveEditor
		));

		this._register(MenuRegistry.appendMenuItem(MenuId.TimelineTitle, ({
			command: {
				id: 'timeline.toggleFollowActiveEditor',
				title: { value: localize('timeline.toggleFollowActiveEditorCommand', "Toggle Active Editor Following"), original: 'Toggle Active Editor Following' },
				// title: localize(`timeline.toggleFollowActiveEditorCommand.stop`, "Stop following the Active Editor"),
				icon: { id: 'codicon/eye' },
				category: { value: localize('timeline', "Timeline"), original: 'Timeline' },
			},
			group: 'navigation',
			order: 98,
			when: TimelineFollowActiveEditorContext
		})));

		this._register(MenuRegistry.appendMenuItem(MenuId.TimelineTitle, ({
			command: {
				id: 'timeline.toggleFollowActiveEditor',
				title: { value: localize('timeline.toggleFollowActiveEditorCommand', "Toggle Active Editor Following"), original: 'Toggle Active Editor Following' },
				// title: localize(`timeline.toggleFollowActiveEditorCommand.stop`, "Stop following the Active Editor"),
				icon: { id: 'codicon/eye-closed' },
				category: { value: localize('timeline', "Timeline"), original: 'Timeline' },
			},
			group: 'navigation',
			order: 98,
			when: TimelineFollowActiveEditorContext.toNegated()
		})));
1099 1100 1101

		this._register(timelineService.onDidChangeProviders(() => this.updateTimelineSourceFilters()));
		this.updateTimelineSourceFilters();
1102 1103
	}

1104
	getItemActions(element: TreeElement): IAction[] {
1105 1106 1107
		return this.getActions(MenuId.TimelineItemContext, { key: 'timelineItem', value: element.contextValue }).primary;
	}

1108
	getItemContextActions(element: TreeElement): IAction[] {
1109 1110 1111 1112
		return this.getActions(MenuId.TimelineItemContext, { key: 'timelineItem', value: element.contextValue }).secondary;
	}

	private getActions(menuId: MenuId, context: { key: string, value?: string }): { primary: IAction[]; secondary: IAction[]; } {
1113 1114 1115
		const scoped = this.contextKeyService.createScoped();
		scoped.createKey('view', this.pane.id);
		scoped.createKey(context.key, context.value);
1116

1117
		const menu = this.menuService.createMenu(menuId, scoped);
1118 1119 1120 1121 1122 1123
		const primary: IAction[] = [];
		const secondary: IAction[] = [];
		const result = { primary, secondary };
		createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, result, this.contextMenuService, g => /^inline/.test(g));

		menu.dispose();
1124
		scoped.dispose();
1125 1126 1127

		return result;
	}
1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160

	private updateTimelineSourceFilters() {
		this.sourceDisposables.clear();

		const excluded = new Set(this.configurationService.getValue<string[] | undefined>('timeline.excludeSources') ?? []);

		for (const source of this.timelineService.getSources()) {
			this.sourceDisposables.add(registerAction2(class extends Action2 {
				constructor() {
					super({
						id: `timeline.toggleExcludeSource:${source.id}`,
						title: { value: localize('timeline.filterSource', "Include: {0}", source.label), original: `Include: ${source.label}` },
						category: { value: localize('timeline', "Timeline"), original: 'Timeline' },
						menu: {
							id: MenuId.TimelineTitle,
							group: '2_sources',
						},
						toggled: ContextKeyExpr.regex(`config.timeline.excludeSources`, new RegExp(`\\b${escapeRegExpCharacters(source.id)}\\b`)).negate()
					});
				}
				run(accessor: ServicesAccessor, ...args: any[]) {
					if (excluded.has(source.id)) {
						excluded.delete(source.id);
					} else {
						excluded.add(source.id);
					}

					const configurationService = accessor.get(IConfigurationService);
					configurationService.updateValue('timeline.excludeSources', [...excluded.keys()]);
				}
			}));
		}
	}
1161
}