Path: blob/main/extensions/git/src/timelineProvider.ts
3316 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken, ConfigurationChangeEvent, Disposable, env, Event, EventEmitter, MarkdownString, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace, l10n, Command } from 'vscode';6import { Model } from './model';7import { Repository, Resource } from './repository';8import { debounce } from './decorators';9import { emojify, ensureEmojis } from './emoji';10import { CommandCenter } from './commands';11import { OperationKind, OperationResult } from './operation';12import { truncate } from './util';13import { CommitShortStat } from './git';14import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';15import { AvatarQuery, AvatarQueryCommit } from './api/git';1617const AVATAR_SIZE = 20;1819export class GitTimelineItem extends TimelineItem {20static is(item: TimelineItem): item is GitTimelineItem {21return item instanceof GitTimelineItem;22}2324readonly ref: string;25readonly previousRef: string;26readonly message: string;2728constructor(29ref: string,30previousRef: string,31message: string,32timestamp: number,33id: string,34contextValue: string35) {36const index = message.indexOf('\n');37const label = index !== -1 ? `${truncate(message, index, false)}` : message;3839super(label, timestamp);4041this.ref = ref;42this.previousRef = previousRef;43this.message = message;44this.id = id;45this.contextValue = contextValue;46}4748get shortRef() {49return this.shortenRef(this.ref);50}5152get shortPreviousRef() {53return this.shortenRef(this.previousRef);54}5556setItemDetails(uri: Uri, hash: string | undefined, shortHash: string | undefined, avatar: string | undefined, author: string, email: string | undefined, date: string, message: string, shortStat?: CommitShortStat, remoteSourceCommands: Command[] = []): void {57this.tooltip = new MarkdownString('', true);58this.tooltip.isTrusted = true;5960const avatarMarkdown = avatar61? ``62: '$(account)';6364if (email) {65const emailTitle = l10n.t('Email');66this.tooltip.appendMarkdown(`${avatarMarkdown} [**${author}**](mailto:${email} "${emailTitle} ${author}")`);67} else {68this.tooltip.appendMarkdown(`${avatarMarkdown} **${author}**`);69}7071this.tooltip.appendMarkdown(`, $(history) ${date}\n\n`);72this.tooltip.appendMarkdown(`${message}\n\n`);7374if (shortStat) {75this.tooltip.appendMarkdown(`---\n\n`);7677const labels: string[] = [];78if (shortStat.insertions) {79labels.push(`<span style="color:var(--vscode-scmGraph-historyItemHoverAdditionsForeground);">${shortStat.insertions === 1 ?80l10n.t('{0} insertion{1}', shortStat.insertions, '(+)') :81l10n.t('{0} insertions{1}', shortStat.insertions, '(+)')}</span>`);82}8384if (shortStat.deletions) {85labels.push(`<span style="color:var(--vscode-scmGraph-historyItemHoverDeletionsForeground);">${shortStat.deletions === 1 ?86l10n.t('{0} deletion{1}', shortStat.deletions, '(-)') :87l10n.t('{0} deletions{1}', shortStat.deletions, '(-)')}</span>`);88}8990this.tooltip.appendMarkdown(`${labels.join(', ')}\n\n`);91}9293if (hash && shortHash) {94this.tooltip.appendMarkdown(`---\n\n`);9596this.tooltip.appendMarkdown(`[\`$(git-commit) ${shortHash} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([uri, hash, uri]))} "${l10n.t('Open Commit')}")`);97this.tooltip.appendMarkdown(' ');98this.tooltip.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`);99100// Remote commands101if (remoteSourceCommands.length > 0) {102this.tooltip.appendMarkdown(' | ');103104const remoteCommandsMarkdown = remoteSourceCommands105.map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`);106this.tooltip.appendMarkdown(remoteCommandsMarkdown.join(' '));107}108}109}110111private shortenRef(ref: string): string {112if (ref === '' || ref === '~' || ref === 'HEAD') {113return ref;114}115return ref.endsWith('^') ? `${ref.substr(0, 8)}^` : ref.substr(0, 8);116}117}118119export class GitTimelineProvider implements TimelineProvider {120private _onDidChange = new EventEmitter<TimelineChangeEvent | undefined>();121get onDidChange(): Event<TimelineChangeEvent | undefined> {122return this._onDidChange.event;123}124125readonly id = 'git-history';126readonly label = l10n.t('Git History');127128private readonly disposable: Disposable;129private providerDisposable: Disposable | undefined;130131private repo: Repository | undefined;132private repoDisposable: Disposable | undefined;133private repoOperationDate: Date | undefined;134135constructor(private readonly model: Model, private commands: CommandCenter) {136this.disposable = Disposable.from(137model.onDidOpenRepository(this.onRepositoriesChanged, this),138workspace.onDidChangeConfiguration(this.onConfigurationChanged, this)139);140141if (model.repositories.length) {142this.ensureProviderRegistration();143}144}145146dispose() {147this.providerDisposable?.dispose();148this.disposable.dispose();149}150151async provideTimeline(uri: Uri, options: TimelineOptions, token: CancellationToken): Promise<Timeline> {152// console.log(`GitTimelineProvider.provideTimeline: uri=${uri}`);153154const repo = this.model.getRepository(uri);155if (!repo) {156this.repoDisposable?.dispose();157this.repoOperationDate = undefined;158this.repo = undefined;159160return { items: [] };161}162163if (this.repo?.root !== repo.root) {164this.repoDisposable?.dispose();165166this.repo = repo;167this.repoOperationDate = new Date();168this.repoDisposable = Disposable.from(169repo.onDidChangeRepository(uri => this.onRepositoryChanged(repo, uri)),170repo.onDidRunGitStatus(() => this.onRepositoryStatusChanged(repo)),171repo.onDidRunOperation(result => this.onRepositoryOperationRun(repo, result))172);173}174175// TODO@eamodio: Ensure that the uri is a file -- if not we could get the history of the repo?176177let limit: number | undefined;178if (options.limit !== undefined && typeof options.limit !== 'number') {179try {180const result = await this.model.git.exec(repo.root, ['rev-list', '--count', `${options.limit.id}..`, '--', uri.fsPath]);181if (!result.exitCode) {182// Ask for 2 more (1 for the limit commit and 1 for the next commit) than so we can determine if there are more commits183limit = Number(result.stdout) + 2;184}185}186catch {187limit = undefined;188}189} else {190// If we are not getting everything, ask for 1 more than so we can determine if there are more commits191limit = options.limit === undefined ? undefined : options.limit + 1;192}193194await ensureEmojis();195196const commits = await repo.logFile(197uri,198{199maxEntries: limit,200hash: options.cursor,201follow: true,202shortStats: true,203// sortByAuthorDate: true204},205token206);207208const paging = commits.length ? {209cursor: limit === undefined ? undefined : (commits.length >= limit ? commits[commits.length - 1]?.hash : undefined)210} : undefined;211212// If we asked for an extra commit, strip it off213if (limit !== undefined && commits.length >= limit) {214commits.splice(commits.length - 1, 1);215}216217const dateFormatter = new Intl.DateTimeFormat(env.language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });218219const config = workspace.getConfiguration('git', Uri.file(repo.root));220const dateType = config.get<'committed' | 'authored'>('timeline.date');221const showAuthor = config.get<boolean>('timeline.showAuthor');222const showUncommitted = config.get<boolean>('timeline.showUncommitted');223const commitShortHashLength = config.get<number>('commitShortHashLength') ?? 7;224225const openComparison = l10n.t('Open Comparison');226227const emptyTree = await repo.getEmptyTree();228const unpublishedCommits = await repo.getUnpublishedCommits();229const remoteHoverCommands = await provideSourceControlHistoryItemHoverCommands(this.model, repo);230231const avatarQuery = {232commits: commits.map(c => ({233hash: c.hash,234authorName: c.authorName,235authorEmail: c.authorEmail236}) satisfies AvatarQueryCommit),237size: 20238} satisfies AvatarQuery;239const avatars = await provideSourceControlHistoryItemAvatar(this.model, repo, avatarQuery);240241const items: GitTimelineItem[] = [];242for (let index = 0; index < commits.length; index++) {243const c = commits[index];244245const date = dateType === 'authored' ? c.authorDate : c.commitDate;246247const message = emojify(c.message);248249const previousRef = commits[index + 1]?.hash ?? emptyTree;250const item = new GitTimelineItem(c.hash, previousRef, message, date?.getTime() ?? 0, c.hash, 'git:file:commit');251item.iconPath = new ThemeIcon('git-commit');252if (showAuthor) {253item.description = c.authorName;254}255256const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteHoverCommands : [];257const messageWithLinks = await provideSourceControlHistoryItemMessageLinks(this.model, repo, message) ?? message;258259item.setItemDetails(uri, c.hash, truncate(c.hash, commitShortHashLength, false), avatars?.get(c.hash), c.authorName!, c.authorEmail, dateFormatter.format(date), messageWithLinks, c.shortStat, commitRemoteSourceCommands);260261const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);262if (cmd) {263item.command = {264title: openComparison,265command: cmd.command,266arguments: cmd.arguments,267};268}269270items.push(item);271}272273if (options.cursor === undefined) {274const you = l10n.t('You');275276const index = repo.indexGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);277if (index) {278const date = this.repoOperationDate ?? new Date();279280const item = new GitTimelineItem('~', 'HEAD', l10n.t('Staged Changes'), date.getTime(), 'index', 'git:file:index');281// TODO@eamodio: Replace with a better icon -- reflecting its status maybe?282item.iconPath = new ThemeIcon('git-commit');283item.description = '';284item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type));285286const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);287if (cmd) {288item.command = {289title: openComparison,290command: cmd.command,291arguments: cmd.arguments,292};293}294295items.splice(0, 0, item);296}297298if (showUncommitted) {299const working = repo.workingTreeGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);300if (working) {301const date = new Date();302303const item = new GitTimelineItem('', index ? '~' : 'HEAD', l10n.t('Uncommitted Changes'), date.getTime(), 'working', 'git:file:working');304item.iconPath = new ThemeIcon('circle-outline');305item.description = '';306item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type));307308const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);309if (cmd) {310item.command = {311title: openComparison,312command: cmd.command,313arguments: cmd.arguments,314};315}316317items.splice(0, 0, item);318}319}320}321322return {323items: items,324paging: paging325};326}327328private ensureProviderRegistration() {329if (this.providerDisposable === undefined) {330this.providerDisposable = workspace.registerTimelineProvider(['file', 'git', 'vscode-remote', 'vscode-local-history'], this);331}332}333334private onConfigurationChanged(e: ConfigurationChangeEvent) {335if (e.affectsConfiguration('git.timeline.date') || e.affectsConfiguration('git.timeline.showAuthor') || e.affectsConfiguration('git.timeline.showUncommitted')) {336this.fireChanged();337}338}339340private onRepositoriesChanged(_repo: Repository) {341// console.log(`GitTimelineProvider.onRepositoriesChanged`);342343this.ensureProviderRegistration();344345// TODO@eamodio: Being naive for now and just always refreshing each time there is a new repository346this.fireChanged();347}348349private onRepositoryChanged(_repo: Repository, _uri: Uri) {350// console.log(`GitTimelineProvider.onRepositoryChanged: uri=${uri.toString(true)}`);351352this.fireChanged();353}354355private onRepositoryStatusChanged(_repo: Repository) {356// console.log(`GitTimelineProvider.onRepositoryStatusChanged`);357358const config = workspace.getConfiguration('git.timeline');359const showUncommitted = config.get<boolean>('showUncommitted') === true;360361if (showUncommitted) {362this.fireChanged();363}364}365366private onRepositoryOperationRun(_repo: Repository, _result: OperationResult) {367// console.log(`GitTimelineProvider.onRepositoryOperationRun`);368369// Successful operations that are not read-only and not status operations370if (!_result.error && !_result.operation.readOnly && _result.operation.kind !== OperationKind.Status) {371// This is less than ideal, but for now just save the last time an372// operation was run and use that as the timestamp for staged items373this.repoOperationDate = new Date();374375this.fireChanged();376}377}378379@debounce(500)380private fireChanged() {381this._onDidChange.fire(undefined);382}383}384385386