timelinePane.ts 37.2 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';
J
João Moreno 已提交
17
import { TreeResourceNavigator, 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
import { Schemas } from 'vs/base/common/network';
42

43
type TreeElement = TimelineItem | LoadMoreCommand;
44

45 46
function isLoadMoreCommand(item: TreeElement | undefined): item is LoadMoreCommand {
	return item instanceof LoadMoreCommand;
47 48 49 50 51 52
}

function isTimelineItem(item: TreeElement | undefined): item is TimelineItem {
	return !item?.handle.startsWith('vscode-command:') ?? false;
}

53 54 55 56 57 58 59 60 61 62 63 64
function updateRelativeTime(item: TimelineItem, lastRelativeTime: string | undefined): string | undefined {
	item.relativeTime = isTimelineItem(item) ? fromNow(item.timestamp) : undefined;
	if (lastRelativeTime === undefined || item.relativeTime !== lastRelativeTime) {
		lastRelativeTime = item.relativeTime;
		item.hideRelativeTime = false;
	} else {
		item.hideRelativeTime = true;
	}

	return lastRelativeTime;
}

65 66 67 68
interface TimelineActionContext {
	uri: URI | undefined;
	item: TreeElement;
}
E
Eric Amodio 已提交
69

70 71
class TimelineAggregate {
	readonly items: TimelineItem[];
72
	readonly source: string;
73 74 75 76

	lastRenderedIndex: number;

	constructor(timeline: Timeline) {
77
		this.source = timeline.source;
78 79 80 81 82 83 84 85 86 87 88
		this.items = timeline.items;
		this._cursor = timeline.paging?.cursor;
		this.lastRenderedIndex = -1;
	}

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

	get more(): boolean {
E
Eric Amodio 已提交
89
		return this._cursor !== undefined;
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
	}

	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;

		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;
	}
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167

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

170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207
class LoadMoreCommand {
	readonly handle = 'vscode-command:loadMore';
	readonly timestamp = 0;
	readonly description = undefined;
	readonly detail = undefined;
	readonly contextValue = undefined;
	// Make things easier for duck typing
	readonly id = undefined;
	readonly icon = undefined;
	readonly iconDark = undefined;
	readonly source = undefined;
	readonly relativeTime = undefined;
	readonly hideRelativeTime = undefined;

	constructor(loading: boolean) {
		this._loading = loading;
	}
	private _loading: boolean = false;
	get loading(): boolean {
		return this._loading;
	}
	set loading(value: boolean) {
		this._loading = value;
	}

	get ariaLabel() {
		return this.label;
	}

	get label() {
		return this.loading ? localize('timeline.loadingMore', "Loading...") : localize('timeline.loadMore', "Load more");
	}

	get themeIcon(): { id: string; } | undefined {
		return undefined; //this.loading ? { id: 'sync~spin' } : undefined;
	}
}

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

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

213 214 215 216 217 218
	private $container!: HTMLElement;
	private $message!: HTMLDivElement;
	private $titleDescription!: HTMLSpanElement;
	private $tree!: HTMLDivElement;
	private tree!: WorkbenchObjectTree<TreeElement, FuzzyScore>;
	private treeRenderer: TimelineTreeRenderer | undefined;
219
	private commands: TimelinePaneCommands;
220 221 222
	private visibilityDisposables: DisposableStore | undefined;

	private followActiveEditorContext: IContextKey<boolean>;
223

224 225 226
	private excludedSources: Set<string>;
	private pendingRequests = new Map<string, TimelineRequest>();
	private timelinesBySource = new Map<string, TimelineAggregate>();
227

228
	private uri: URI | undefined;
229

230 231 232 233 234 235 236 237 238
	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,
239
		@ICommandService protected commandService: ICommandService,
E
Eric Amodio 已提交
240
		@IProgressService private readonly progressService: IProgressService,
241 242 243
		@ITimelineService protected timelineService: ITimelineService,
		@IOpenerService openerService: IOpenerService,
		@IThemeService themeService: IThemeService,
244
		@ITelemetryService telemetryService: ITelemetryService,
245
	) {
246
		super({ ...options, titleMenuId: MenuId.TimelineTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService);
247

248
		this.commands = this._register(this.instantiationService.createInstance(TimelinePaneCommands, this));
249

250
		this.followActiveEditorContext = TimelineFollowActiveEditorContext.bindTo(this.contextKeyService);
251

252
		this.excludedSources = new Set(configurationService.getValue('timeline.excludeSources'));
253
		configurationService.onDidChangeConfiguration(this.onConfigurationChanged, this);
254

255 256
		this._register(timelineService.onDidChangeProviders(this.onProvidersChanged, this));
		this._register(timelineService.onDidChangeTimeline(this.onTimelineChanged, this));
257 258 259 260 261 262 263 264 265 266 267 268 269
		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;
270
		this.followActiveEditorContext.set(value);
271 272 273 274 275 276

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

277 278 279 280 281
	get pageSize() {
		const pageSize = this.configurationService.getValue<number | null | undefined>('timeline.pageSize') ?? Math.max(20, Math.floor((this.tree.renderHeight / 22) - 1));
		return pageSize;
	}

282 283 284 285 286 287 288 289 290 291 292 293 294
	reset() {
		this.loadTimeline(true);
	}

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

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

295
		this.uri = uri;
296
		this.titleDescription = uri ? basename(uri.fsPath) : '';
297
		this.treeRenderer?.setUri(uri);
298
		this.loadTimeline(true);
299 300 301 302 303 304 305
	}

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

306 307 308 309 310 311 312 313 314
		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();
		}
315 316
	}

317
	private onActiveEditorChanged() {
318 319 320 321
		if (!this.followActiveEditor) {
			return;
		}

322 323 324 325 326 327 328
		let uri;

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

329
		if ((uri?.toString(true) === this.uri?.toString(true) && uri !== undefined) ||
330
			// Fallback to match on fsPath if we are dealing with files or git schemes
331
			(uri?.fsPath === this.uri?.fsPath && (uri?.scheme === 'file' || uri?.scheme === 'git') && (this.uri?.scheme === 'file' || this.uri?.scheme === 'git'))) {
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350

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

351 352 353
			return;
		}

354
		this.setUriCore(uri, false);
355 356
	}

357 358 359
	private onProvidersChanged(e: TimelineProvidersChangeEvent) {
		if (e.removed) {
			for (const source of e.removed) {
360
				this.timelinesBySource.delete(source);
361
			}
362 363

			this.refresh();
364 365 366
		}

		if (e.added) {
367
			this.loadTimeline(true, e.added);
368
		}
369 370
	}

371
	private onTimelineChanged(e: TimelineChangeEvent) {
372 373 374 375 376 377
		if (e?.uri === undefined || e.uri.toString(true) !== this.uri?.toString(true)) {
			const timeline = this.timelinesBySource.get(e.id);
			if (timeline === undefined) {
				return;
			}

378
			if (this.isBodyVisible()) {
E
Eric Amodio 已提交
379
				this.updateTimeline(timeline, e.reset);
380
			} else {
E
Eric Amodio 已提交
381
				timeline.invalidate(e.reset);
382
			}
383 384 385
		}
	}

386 387 388 389 390 391 392
	private _titleDescription: string | undefined;
	get titleDescription(): string | undefined {
		return this._titleDescription;
	}

	set titleDescription(description: string | undefined) {
		this._titleDescription = description;
393
		this.$titleDescription.textContent = description ?? '';
394 395
	}

E
Eric Amodio 已提交
396 397 398 399 400 401 402 403 404 405 406
	private _message: string | undefined;
	get message(): string | undefined {
		return this._message;
	}

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

	private updateMessage(): void {
407
		if (this._message !== undefined) {
E
Eric Amodio 已提交
408 409 410 411 412 413 414
			this.showMessage(this._message);
		} else {
			this.hideMessage();
		}
	}

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

418
		this.$message.textContent = message;
E
Eric Amodio 已提交
419 420 421 422
	}

	private hideMessage(): void {
		this.resetMessageElement();
423
		DOM.addClass(this.$message, 'hide');
E
Eric Amodio 已提交
424 425 426
	}

	private resetMessageElement(): void {
427
		DOM.clearNode(this.$message);
E
Eric Amodio 已提交
428 429
	}

430 431 432 433 434 435 436
	private _isEmpty = true;
	private _maxItemCount = 0;

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

438 439
	private clear(cancelPending: boolean) {
		this._visibleItemCount = 0;
440
		this._maxItemCount = this.pageSize;
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456
		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;
			}
		}
	}

457
	private async loadTimeline(reset: boolean, sources?: string[]) {
458 459
		// If we have no source, we are reseting all sources, so cancel everything in flight and reset caches
		if (sources === undefined) {
460
			if (reset) {
461
				this.clear(true);
462
			}
E
Eric Amodio 已提交
463

464
			// TODO@eamodio: Are these the right the list of schemes to exclude? Is there a better way?
465
			if (this.uri?.scheme === Schemas.vscodeSettings || this.uri?.scheme === Schemas.webviewPanel || this.uri?.scheme === Schemas.walkThrough) {
466 467
				this.uri = undefined;

468
				this.clear(false);
469
				this.refresh();
470 471 472

				return;
			}
473

474
			if (this._isEmpty && this.uri !== undefined) {
475
				this.setLoadingUriMessage();
E
Eric Amodio 已提交
476
			}
477 478
		}

479
		if (this.uri === undefined) {
480
			this.clear(false);
481 482
			this.refresh();

483 484 485
			return;
		}

486 487 488 489
		if (!this.isBodyVisible()) {
			return;
		}

490
		let hasPendingRequests = false;
491

492 493 494 495 496
		for (const source of sources ?? this.timelineService.getSources().map(s => s.id)) {
			const requested = this.loadTimelineForSource(source, this.uri, reset);
			if (requested) {
				hasPendingRequests = true;
			}
497 498
		}

499 500 501 502
		if (!hasPendingRequests) {
			this.refresh();
		} else if (this._isEmpty) {
			this.setLoadingUriMessage();
503
		}
504
	}
505

506 507 508 509
	private loadTimelineForSource(source: string, uri: URI, reset: boolean, options?: TimelineOptions) {
		if (this.excludedSources.has(source)) {
			return false;
		}
510

511
		const timeline = this.timelinesBySource.get(source);
512

513 514 515 516
		// 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 &&
517
			options?.cursor !== undefined &&
518
			timeline !== undefined &&
519
			(!timeline?.more || timeline.items.length > timeline.lastRenderedIndex + this.pageSize)
520 521 522
		) {
			return false;
		}
523

524
		if (options === undefined) {
525
			options = { cursor: reset ? undefined : timeline?.cursor, limit: this.pageSize };
526
		}
527

528 529 530
		let request = this.pendingRequests.get(source);
		if (request !== undefined) {
			options.cursor = request.options.cursor;
E
Eric Amodio 已提交
531

532 533 534 535 536 537
			// 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 已提交
538
				}
539
			}
540
		}
541
		request?.tokenSource.dispose(true);
542

543 544 545 546 547 548
		request = this.timelineService.getTimeline(
			source, uri, options, new CancellationTokenSource(), { cacheResults: true, resetCache: reset }
		);

		if (request === undefined) {
			return false;
549
		}
550 551 552 553 554 555 556

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

		this.handleRequest(request);

		return true;
557 558
	}

559 560 561 562 563 564 565 566 567
	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;
568
			this.loadTimelineForSource(timeline.source, this.uri!, false, newest !== undefined ? { limit: { timestamp: newest.timestamp, id: newest.id } } : { limit: this.pageSize });
569 570 571
		}
	}

572 573
	private _pendingRefresh = false;

574
	private async handleRequest(request: TimelineRequest) {
575
		let response: Timeline | undefined;
576
		try {
577
			response = await this.progressService.withProgress({ location: this.id }, () => request.result);
578 579
		}
		finally {
580
			this.pendingRequests.delete(request.source);
581
		}
582

583
		if (
584
			response === undefined ||
585
			request.tokenSource.token.isCancellationRequested ||
586
			request.uri !== this.uri
587
		) {
588
			if (this.pendingRequests.size === 0 && this._pendingRefresh) {
589 590 591 592 593 594
				this.refresh();
			}

			return;
		}

595 596
		const source = request.source;

597 598 599 600 601
		let updated = false;
		const timeline = this.timelinesBySource.get(source);
		if (timeline === undefined) {
			this.timelinesBySource.set(source, new TimelineAggregate(response));
			updated = true;
602
		}
603 604
		else {
			updated = timeline.add(response);
605
		}
606

607 608 609 610 611 612 613
		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 {
614 615
				this.refresh();
			}
616 617 618 619 620 621
		} else if (this.pendingRequests.size === 0) {
			if (this._pendingRefresh) {
				this.refresh();
			} else {
				this.tree.rerender();
			}
622 623 624 625 626 627 628 629
		}
	}

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

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

E
Eric Amodio 已提交
631 632 633
			return;
		}

634 635
		const maxCount = this._maxItemCount;
		let count = 0;
636

637 638 639 640 641 642 643 644 645 646 647 648 649 650
		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;
651
			}
652 653 654

			more = timeline.more;

655
			let lastRelativeTime: string | undefined;
656
			for (const item of timeline.items) {
657 658 659
				item.relativeTime = undefined;
				item.hideRelativeTime = undefined;

660 661 662 663
				count++;
				if (count > maxCount) {
					more = true;
					break;
664
				}
665

666
				lastRelativeTime = updateRelativeTime(item, lastRelativeTime);
667
				yield { element: item };
668 669
			}

670
			timeline.lastRenderedIndex = count - 1;
671
		}
672 673
		else {
			const sources: { timeline: TimelineAggregate; iterator: IterableIterator<TimelineItem>; nextItem: IteratorResult<TimelineItem, TimelineItem> }[] = [];
674

675 676
			let hasAnyItems = false;
			let mostRecentEnd = 0;
677

678 679
			for (const [source, timeline] of this.timelinesBySource) {
				timeline.lastRenderedIndex = -1;
680

681
				if (this.excludedSources.has(source) || timeline.stale) {
682
					continue;
683
				}
684 685 686

				if (timeline.items.length !== 0) {
					hasAnyItems = true;
687 688
				}

689 690 691 692 693 694 695
				if (timeline.more) {
					more = true;

					const last = timeline.items[Math.min(maxCount, timeline.items.length - 1)];
					if (last.timestamp > mostRecentEnd) {
						mostRecentEnd = last.timestamp;
					}
696
				}
697 698 699

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

702
			this._visibleItemCount = hasAnyItems ? 1 : 0;
703

704 705 706 707 708
			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!);
			}
709

710
			let lastRelativeTime: string | undefined;
711 712 713
			let nextSource;
			while (nextSource = getNextMostRecentSource()) {
				nextSource.timeline.lastRenderedIndex++;
714

715 716 717 718 719
				const item = nextSource.nextItem.value;
				item.relativeTime = undefined;
				item.hideRelativeTime = undefined;

				if (item.timestamp >= mostRecentEnd) {
720 721 722 723 724
					count++;
					if (count > maxCount) {
						more = true;
						break;
					}
725

726 727
					lastRelativeTime = updateRelativeTime(item, lastRelativeTime);
					yield { element: item };
728
				}
729

730 731
				nextSource.nextItem = nextSource.iterator.next();
			}
732 733
		}

734
		this._visibleItemCount = count;
735

736 737
		if (more) {
			yield {
738 739 740 741 742
				element: new LoadMoreCommand(this.pendingRequests.size !== 0)
			};
		} else if (this.pendingRequests.size !== 0) {
			yield {
				element: new LoadMoreCommand(true)
743 744
			};
		}
745 746
	}

747
	private refresh() {
748 749 750 751
		if (!this.isBodyVisible()) {
			return;
		}

752 753 754 755
		this.tree.setChildren(null, this.getItems() as any);
		this._isEmpty = !this.hasVisibleItems;

		if (this.uri === undefined) {
756
			this.titleDescription = undefined;
757
			this.message = localize('timeline.editorCannotProvideTimeline', "The active editor cannot provide timeline information.");
758 759
		} else if (this._isEmpty) {
			if (this.pendingRequests.size !== 0) {
760
				this.setLoadingUriMessage();
761
			} else {
762
				this.titleDescription = basename(this.uri.fsPath);
763
				this.message = localize('timeline.noTimelineInfo', "No timeline information was provided.");
764
			}
765
		} else {
766
			this.titleDescription = basename(this.uri.fsPath);
767
			this.message = undefined;
768 769
		}

770
		this._pendingRefresh = false;
771 772 773 774
	}

	@debounce(500)
	private refreshDebounced() {
775 776 777 778 779
		this.refresh();
	}

	focus(): void {
		super.focus();
780
		this.tree.domFocus();
781 782
	}

783 784 785 786
	setExpanded(expanded: boolean): boolean {
		const changed = super.setExpanded(expanded);

		if (changed && this.isBodyVisible()) {
E
Eric Amodio 已提交
787 788 789 790 791
			if (!this.followActiveEditor) {
				this.setUriCore(this.uri, true);
			} else {
				this.onActiveEditorChanged();
			}
792 793 794 795 796
		}

		return changed;
	}

797 798
	setVisible(visible: boolean): void {
		if (visible) {
799
			this.visibilityDisposables = new DisposableStore();
800

801
			this.editorService.onDidActiveEditorChange(this.onActiveEditorChanged, this, this.visibilityDisposables);
802 803
			// Refresh the view on focus to update the relative timestamps
			this.onDidFocus(() => this.refreshDebounced(), this, this.visibilityDisposables);
804

805 806
			super.setVisible(visible);

807 808
			this.onActiveEditorChanged();
		} else {
809
			this.visibilityDisposables?.dispose();
810

811 812
			super.setVisible(visible);
		}
813 814 815
	}

	protected layoutBody(height: number, width: number): void {
816
		this.tree.layout(height, width);
817 818
	}

819 820 821 822
	protected renderHeaderTitle(container: HTMLElement): void {
		super.renderHeaderTitle(container, this.title);

		DOM.addClass(container, 'timeline-view');
823
		this.$titleDescription = DOM.append(container, DOM.$('span.description', undefined, this.titleDescription ?? ''));
824 825
	}

826
	protected renderBody(container: HTMLElement): void {
827 828
		super.renderBody(container);

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

832 833
		this.$message = DOM.append(this.$container, DOM.$('.message'));
		DOM.addClass(this.$message, 'timeline-subtle');
E
Eric Amodio 已提交
834

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

837 838 839 840
		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);
841

842 843 844
		this.treeRenderer = this.instantiationService.createInstance(TimelineTreeRenderer, this.commands);
		this.tree = <WorkbenchObjectTree<TreeElement, FuzzyScore>>this.instantiationService.createInstance(WorkbenchObjectTree, 'TimelinePane',
			this.$tree, new TimelineListVirtualDelegate(), [this.treeRenderer], {
845
			identityProvider: new TimelineIdentityProvider(),
846 847
			accessibilityProvider: {
				getAriaLabel(element: TreeElement): string {
848 849
					if (isLoadMoreCommand(element)) {
						return element.ariaLabel;
850
					}
851
					return element.ariaLabel ?? localize('timeline.aria.item', "{0}: {1}", element.relativeTime ?? '', element.label);
852 853 854
				}
			},
			ariaLabel: this.title,
S
SteVen Batten 已提交
855 856
			keyboardNavigationLabelProvider: new TimelineKeyboardNavigationLabelProvider(),
			overrideStyles: {
857 858
				listBackground: this.getBackgroundColor(),

S
SteVen Batten 已提交
859
			}
860 861
		});

J
João Moreno 已提交
862
		const customTreeNavigator = new TreeResourceNavigator(this.tree, { openOnFocus: false, openOnSelection: false });
863
		this._register(customTreeNavigator);
864 865
		this._register(this.tree.onContextMenu(e => this.onContextMenu(this.commands, e)));
		this._register(this.tree.onDidChangeSelection(e => this.ensureValidItems()));
866 867
		this._register(
			customTreeNavigator.onDidOpenResource(e => {
868
				if (!e.browserEvent || !this.ensureValidItems()) {
869 870 871
					return;
				}

872
				const selection = this.tree.getSelection();
873 874 875 876 877 878 879 880 881 882 883
				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 || []));
					}
				}
884 885 886 887
				else if (isLoadMoreCommand(item)) {
					item.loading = true;
					this.tree.rerender(item);

888
					if (this.pendingRequests.size !== 0) {
889 890 891
						return;
					}

892
					this._maxItemCount = this._visibleItemCount + this.pageSize;
893
					this.loadTimeline(false);
894 895
				}
			})
896 897
		);
	}
898
	ensureValidItems() {
899 900 901 902
		// 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;
903 904 905 906 907 908 909 910 911 912

			this.setLoadingUriMessage();

			return false;
		}

		return true;
	}

	setLoadingUriMessage() {
913
		const file = this.uri && basename(this.uri.fsPath);
914
		this.titleDescription = file ?? '';
915
		this.message = file ? localize('timeline.loading', "Loading timeline for {0}...", file) : '';
916
	}
917

918
	private onContextMenu(commands: TimelinePaneCommands, treeEvent: ITreeContextMenuEvent<TreeElement | null>): void {
919 920 921 922 923 924 925 926 927
		const item = treeEvent.element;
		if (item === null) {
			return;
		}
		const event: UIEvent = treeEvent.browserEvent;

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

928 929 930 931
		if (!this.ensureValidItems()) {
			return;
		}

932
		this.tree.setFocus([item]);
933
		const actions = commands.getItemContextActions(item);
934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949
		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) {
950
					this.tree.domFocus();
951 952
				}
			},
953
			getActionsContext: (): TimelineActionContext => ({ uri: this.uri, item: item }),
954 955 956
			actionRunner: new TimelineActionRunner()
		});
	}
957 958
}

959
export class TimelineElementTemplate implements IDisposable {
960 961
	static readonly id = 'TimelineElementTemplate';

962 963 964 965 966
	readonly actionBar: ActionBar;
	readonly icon: HTMLElement;
	readonly iconLabel: IconLabel;
	readonly timestamp: HTMLSpanElement;

967 968
	constructor(
		readonly container: HTMLElement,
969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988
		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() {
989 990
		this.icon.className = '';
		this.icon.style.backgroundImage = '';
991 992 993 994 995 996 997 998
		this.actionBar.clear();
	}
}

export class TimelineIdentityProvider implements IIdentityProvider<TreeElement> {
	getId(item: TreeElement): { toString(): string } {
		return item.handle;
	}
999 1000
}

1001 1002 1003
class TimelineActionRunner extends ActionRunner {

	runAction(action: IAction, { uri, item }: TimelineActionContext): Promise<any> {
1004
		if (!isTimelineItem(item)) {
1005
			// TODO@eamodio do we need to do anything else?
1006 1007 1008
			return action.run();
		}

1009 1010 1011 1012 1013 1014 1015 1016 1017 1018
		return action.run(...[
			{
				$mid: 11,
				handle: item.handle,
				source: item.source,
				uri: uri
			},
			uri,
			item.source,
		]);
1019 1020 1021
	}
}

1022 1023
export class TimelineKeyboardNavigationLabelProvider implements IKeyboardNavigationLabelProvider<TreeElement> {
	getKeyboardNavigationLabel(element: TreeElement): { toString(): string } {
1024 1025 1026 1027
		return element.label;
	}
}

1028 1029
export class TimelineListVirtualDelegate implements IListVirtualDelegate<TreeElement> {
	getHeight(_element: TreeElement): number {
1030 1031 1032
		return 22;
	}

1033
	getTemplateId(element: TreeElement): string {
1034 1035 1036 1037 1038 1039 1040
		return TimelineElementTemplate.id;
	}
}

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

1041
	private actionViewItemProvider: IActionViewItemProvider;
1042

1043
	constructor(
1044
		private readonly commands: TimelinePaneCommands,
1045
		@IInstantiationService protected readonly instantiationService: IInstantiationService,
1046
		@IThemeService private themeService: IThemeService
1047
	) {
1048
		this.actionViewItemProvider = (action: IAction) => action instanceof MenuItemAction
1049 1050 1051 1052
			? this.instantiationService.createInstance(ContextAwareMenuEntryActionViewItem, action)
			: undefined;
	}

1053
	private uri: URI | undefined;
1054
	setUri(uri: URI | undefined) {
1055
		this.uri = uri;
1056
	}
1057

1058
	renderTemplate(container: HTMLElement): TimelineElementTemplate {
1059
		return new TimelineElementTemplate(container, this.actionViewItemProvider);
1060 1061
	}

1062 1063 1064 1065 1066 1067
	renderElement(
		node: ITreeNode<TreeElement, FuzzyScore>,
		index: number,
		template: TimelineElementTemplate,
		height: number | undefined
	): void {
1068
		template.reset();
1069

1070 1071
		const { element: item } = node;

1072
		const icon = this.themeService.getColorTheme().type === LIGHT ? item.icon : item.iconDark;
1073 1074 1075 1076 1077 1078 1079
		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;
1080 1081
			if (item.themeIcon /*&& !this.isFileKindThemeIcon(element.themeIcon)*/) {
				iconClass = ThemeIcon.asClassName(item.themeIcon);
1082 1083 1084 1085
			}
			template.icon.className = iconClass ? `custom-view-tree-node-item-icon ${iconClass}` : '';
		}

1086 1087
		template.iconLabel.setLabel(item.label, item.description, {
			title: item.detail,
1088 1089
			matches: createMatches(node.filterData)
		});
1090

1091 1092
		template.timestamp.textContent = item.relativeTime ?? '';
		DOM.toggleClass(template.timestamp.parentElement!, 'timeline-timestamp--duplicate', isTimelineItem(item) && item.hideRelativeTime);
1093

1094
		template.actionBar.context = { uri: this.uri, item: item } as TimelineActionContext;
1095
		template.actionBar.actionRunner = new TimelineActionRunner();
1096
		template.actionBar.push(this.commands.getItemActions(item), { icon: true, label: false });
1097 1098 1099 1100 1101 1102
	}

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

1104 1105
class TimelinePaneCommands extends Disposable {
	private sourceDisposables: DisposableStore;
1106 1107

	constructor(
1108 1109 1110
		private readonly pane: TimelinePane,
		@ITimelineService private readonly timelineService: ITimelineService,
		@IConfigurationService private readonly configurationService: IConfigurationService,
1111 1112 1113 1114 1115
		@IContextKeyService private readonly contextKeyService: IContextKeyService,
		@IMenuService private readonly menuService: IMenuService,
		@IContextMenuService private readonly contextMenuService: IContextMenuService
	) {
		super();
1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137

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

1138 1139 1140 1141 1142 1143 1144
		this._register(CommandsRegistry.registerCommand('timeline.toggleFollowActiveEditor',
			(accessor: ServicesAccessor, ...args: any[]) => pane.followActiveEditor = !pane.followActiveEditor
		));

		this._register(MenuRegistry.appendMenuItem(MenuId.TimelineTitle, ({
			command: {
				id: 'timeline.toggleFollowActiveEditor',
1145
				title: { value: localize('timeline.toggleFollowActiveEditorCommand.follow', "Automatically Follows the Active Editor"), original: 'Automatically Follows the Active Editor' },
1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156
				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',
1157
				title: { value: localize('timeline.toggleFollowActiveEditorCommand.unfollow', "Not Following Active Editor"), original: 'Not Following Active Editor' },
1158 1159 1160 1161 1162 1163 1164
				icon: { id: 'codicon/eye-closed' },
				category: { value: localize('timeline', "Timeline"), original: 'Timeline' },
			},
			group: 'navigation',
			order: 98,
			when: TimelineFollowActiveEditorContext.toNegated()
		})));
1165 1166 1167

		this._register(timelineService.onDidChangeProviders(() => this.updateTimelineSourceFilters()));
		this.updateTimelineSourceFilters();
1168 1169
	}

1170
	getItemActions(element: TreeElement): IAction[] {
1171 1172 1173
		return this.getActions(MenuId.TimelineItemContext, { key: 'timelineItem', value: element.contextValue }).primary;
	}

1174
	getItemContextActions(element: TreeElement): IAction[] {
1175 1176 1177 1178
		return this.getActions(MenuId.TimelineItemContext, { key: 'timelineItem', value: element.contextValue }).secondary;
	}

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

1183
		const menu = this.menuService.createMenu(menuId, scoped);
1184 1185 1186 1187 1188 1189
		const primary: IAction[] = [];
		const secondary: IAction[] = [];
		const result = { primary, secondary };
		createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, result, this.contextMenuService, g => /^inline/.test(g));

		menu.dispose();
1190
		scoped.dispose();
1191 1192 1193

		return result;
	}
1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226

	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()]);
				}
			}));
		}
	}
1227
}