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 282
	get pageSize() {
		const pageSize = this.configurationService.getValue<number | null | undefined>('timeline.pageSize') ?? Math.max(20, Math.floor((this.tree.renderHeight / 22) - 1));
		console.log(pageSize);
		return pageSize;
	}

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

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

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

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

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

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

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

323 324 325 326 327 328 329
		let uri;

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

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

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

352 353 354
			return;
		}

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

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

			this.refresh();
365 366 367
		}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

				return;
			}
474

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

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

484 485 486
			return;
		}

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

491
		let hasPendingRequests = false;
492

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

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

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

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

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

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

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

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

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

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

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

		this.handleRequest(request);

		return true;
558 559
	}

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

573 574
	private _pendingRefresh = false;

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

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

			return;
		}

596 597
		const source = request.source;

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

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

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

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

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

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

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

			more = timeline.more;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

735
		this._visibleItemCount = count;
736

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

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

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

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

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

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

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

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

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

		return changed;
	}

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

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

806 807
			super.setVisible(visible);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

			this.setLoadingUriMessage();

			return false;
		}

		return true;
	}

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

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

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

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

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

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

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

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

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

1002 1003 1004
class TimelineActionRunner extends ActionRunner {

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

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

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

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

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

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

1042
	private actionViewItemProvider: IActionViewItemProvider;
1043

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

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

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

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

1071 1072
		const { element: item } = node;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

		return result;
	}
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 1227

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