331 lines
9.3 KiB

'use strict';
import { commands, Disposable, TextEditor, Uri, window } from 'vscode';
import { ShowQuickCommitCommandArgs } from '../../../commands';
import { Commands } from '../../../constants';
import { Container } from '../../../container';
import { PremiumFeatures } from '../../../features';
import { GitUri } from '../../../git/gitUri';
import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models';
import { createFromDateDelta } from '../../../system/date';
import { debug } from '../../../system/decorators/log';
import { debounce, Deferrable } from '../../../system/function';
import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils';
import { IpcMessage, onIpc } from '../../../webviews/protocol';
import { WebviewViewBase } from '../../../webviews/webviewViewBase';
import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService';
import {
Commit,
DidChangeStateNotificationType,
OpenDataPointCommandType,
Period,
State,
UpdatePeriodCommandType,
} from './protocol';
interface Context {
uri: Uri | undefined;
period: Period | undefined;
etagRepository: number | undefined;
etagSubscription: number | undefined;
}
const defaultPeriod: Period = '3|M';
export class TimelineWebviewView extends WebviewViewBase<State> {
private _bootstraping = true;
/** The context the webview has */
private _context: Context;
/** The context the webview should have */
private _pendingContext: Partial<Context> | undefined;
constructor(container: Container) {
super(container, 'gitlens.views.timeline', 'timeline.html', 'Visual File History');
this._context = {
uri: undefined,
period: defaultPeriod,
etagRepository: 0,
etagSubscription: 0,
};
}
protected override onInitializing(): Disposable[] | undefined {
this._context = {
uri: undefined,
period: defaultPeriod,
etagRepository: 0,
etagSubscription: this.container.subscription.etag,
};
return [
this.container.subscription.onDidChange(this.onSubscriptionChanged, this),
window.onDidChangeActiveTextEditor(this.onActiveEditorChanged, this),
this.container.git.onDidChangeRepository(this.onRepositoryChanged, this),
];
}
protected override async includeBootstrap(): Promise<State> {
this._bootstraping = true;
this.updatePendingEditor(window.activeTextEditor);
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
return this.getState(this._context);
}
protected override registerCommands(): Disposable[] {
return [
commands.registerCommand(`${this.id}.refresh`, () => this.refresh(), this),
commands.registerCommand(`${this.id}.openInTab`, () => this.openInTab(), this),
];
}
protected override onVisibilityChanged(visible: boolean) {
if (!visible) return;
// Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data
if (this._bootstraping) {
this._bootstraping = false;
return;
}
this.updateState(true);
}
protected override onMessageReceived(e: IpcMessage) {
switch (e.method) {
case OpenDataPointCommandType.method:
onIpc(OpenDataPointCommandType, e, params => {
if (params.data == null || !params.data.selected || this._context.uri == null) return;
const repository = this.container.git.getRepository(this._context.uri);
if (repository == null) return;
const commandArgs: ShowQuickCommitCommandArgs = {
repoPath: repository.path,
sha: params.data.id,
};
void commands.executeCommand(Commands.ShowQuickCommit, commandArgs);
// const commandArgs: DiffWithPreviousCommandArgs = {
// line: 0,
// showOptions: {
// preserveFocus: true,
// preview: true,
// viewColumn: ViewColumn.Beside,
// },
// };
// void commands.executeCommand(
// Commands.DiffWithPrevious,
// new GitUri(gitUri, { repoPath: gitUri.repoPath!, sha: params.data.id }),
// commandArgs,
// );
});
break;
case UpdatePeriodCommandType.method:
onIpc(UpdatePeriodCommandType, e, params => {
if (this.updatePendingContext({ period: params.period })) {
this.updateState(true);
}
});
break;
}
}
@debug({ args: false })
private onActiveEditorChanged(editor: TextEditor | undefined) {
if (!this.updatePendingEditor(editor)) return;
this.updateState();
}
@debug({ args: false })
private onRepositoryChanged(e: RepositoryChangeEvent) {
if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) {
return;
}
if (this.updatePendingContext({ etagRepository: e.repository.etag })) {
this.updateState();
}
}
@debug({ args: false })
private onSubscriptionChanged(e: SubscriptionChangeEvent) {
if (this.updatePendingContext({ etagSubscription: e.etag })) {
this.updateState();
}
}
@debug({ args: false })
private async getState(current: Context): Promise<State> {
const access = await this.container.git.access(PremiumFeatures.Timeline);
const period = current.period ?? defaultPeriod;
const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma';
if (current.uri == null || !access.allowed) {
return {
period: period,
title: 'There are no editors open that can provide file history information',
dateFormat: dateFormat,
access: access,
};
}
const gitUri = await GitUri.fromUri(current.uri);
const repoPath = gitUri.repoPath!;
const title = gitUri.relativePath;
this.description = gitUri.fileName;
const [currentUser, log] = await Promise.all([
this.container.git.getCurrentUser(repoPath),
this.container.git.getLogForFile(repoPath, gitUri.fsPath, {
limit: 0,
ref: gitUri.sha,
since: this.getPeriodDate(period).toISOString(),
}),
]);
if (log == null) {
return {
dataset: [],
period: period,
title: 'No commits found for the specified time period',
uri: current.uri.toString(),
dateFormat: dateFormat,
access: access,
};
}
const name = currentUser?.name ? `${currentUser.name} (you)` : 'You';
const dataset: Commit[] = [];
for (const commit of log.commits.values()) {
const stats = commit.file?.stats;
dataset.push({
author: commit.author.name === 'You' ? name : commit.author.name,
additions: stats?.additions ?? 0,
// changed: stats?.changes ?? 0,
deletions: stats?.deletions ?? 0,
commit: commit.sha,
date: commit.date.toISOString(),
message: commit.message ?? commit.summary,
sort: commit.date.getTime(),
});
}
dataset.sort((a, b) => b.sort - a.sort);
return {
dataset: dataset,
period: period,
title: title,
uri: current.uri.toString(),
dateFormat: dateFormat,
access: access,
};
}
private getPeriodDate(period: Period): Date {
const [number, unit] = period.split('|');
switch (unit) {
case 'D':
return createFromDateDelta(new Date(), { days: -parseInt(number, 10) });
case 'M':
return createFromDateDelta(new Date(), { months: -parseInt(number, 10) });
case 'Y':
return createFromDateDelta(new Date(), { years: -parseInt(number, 10) });
default:
return createFromDateDelta(new Date(), { months: -3 });
}
}
private openInTab() {
const uri = this._context.uri;
if (uri == null) return;
void commands.executeCommand(Commands.ShowTimelinePage, uri);
}
private updatePendingContext(context: Partial<Context>): boolean {
let changed = false;
for (const [key, value] of Object.entries(context)) {
const current = (this._context as unknown as Record<string, unknown>)[key];
if (
current === value ||
((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString())
) {
continue;
}
if (this._pendingContext == null) {
this._pendingContext = {};
}
(this._pendingContext as Record<string, unknown>)[key] = value;
changed = true;
}
return changed;
}
private updatePendingEditor(editor: TextEditor | undefined): boolean {
if (editor == null && hasVisibleTextEditor()) return false;
if (editor != null && !isTextEditor(editor)) return false;
let etag;
if (editor != null) {
const repository = this.container.git.getRepository(editor.document.uri);
etag = repository?.etag ?? 0;
} else {
etag = 0;
}
return this.updatePendingContext({ uri: editor?.document.uri, etagRepository: etag });
}
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;
@debug()
private updateState(immediate: boolean = false) {
if (!this.isReady || !this.visible) return;
this.updatePendingEditor(window.activeTextEditor);
if (immediate) {
void this.notifyDidChangeState();
return;
}
if (this._notifyDidChangeStateDebounced == null) {
this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500);
}
this._notifyDidChangeStateDebounced();
}
@debug()
private async notifyDidChangeState() {
if (!this.isReady || !this.visible) return false;
this._notifyDidChangeStateDebounced?.cancel();
const context = { ...this._context, ...this._pendingContext };
return window.withProgress({ location: { viewId: this.id } }, async () => {
const success = await this.notify(DidChangeStateNotificationType, {
state: await this.getState(context),
});
if (success) {
this._context = context;
this._pendingContext = undefined;
}
});
}
}