Path: blob/main/extensions/git/src/timelineProvider.ts
5240 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, Event, EventEmitter, 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 { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';14import { AvatarQuery, AvatarQueryCommit } from './api/git';15import { getCommitHover, getHoverCommitHashCommands, processHoverRemoteCommands } from './hover';1617export class GitTimelineItem extends TimelineItem {18static is(item: TimelineItem): item is GitTimelineItem {19return item instanceof GitTimelineItem;20}2122readonly ref: string;23readonly previousRef: string;24readonly message: string;2526constructor(27ref: string,28previousRef: string,29message: string,30timestamp: number,31id: string,32contextValue: string33) {34const index = message.indexOf('\n');35const label = index !== -1 ? `${truncate(message, index, false)}` : message;3637super(label, timestamp);3839this.ref = ref;40this.previousRef = previousRef;41this.message = message;42this.id = id;43this.contextValue = contextValue;44}4546get shortRef() {47return this.shortenRef(this.ref);48}4950get shortPreviousRef() {51return this.shortenRef(this.previousRef);52}5354private shortenRef(ref: string): string {55if (ref === '' || ref === '~' || ref === 'HEAD') {56return ref;57}58return ref.endsWith('^') ? `${ref.substr(0, 8)}^` : ref.substr(0, 8);59}60}6162export class GitTimelineProvider implements TimelineProvider {63private _onDidChange = new EventEmitter<TimelineChangeEvent | undefined>();64get onDidChange(): Event<TimelineChangeEvent | undefined> {65return this._onDidChange.event;66}6768readonly id = 'git-history';69readonly label = l10n.t('Git History');7071private readonly disposable: Disposable;72private providerDisposable: Disposable | undefined;7374private repo: Repository | undefined;75private repoDisposable: Disposable | undefined;76private repoOperationDate: Date | undefined;7778constructor(private readonly model: Model, private commands: CommandCenter) {79this.disposable = Disposable.from(80model.onDidOpenRepository(this.onRepositoriesChanged, this),81workspace.onDidChangeConfiguration(this.onConfigurationChanged, this)82);8384if (model.repositories.length) {85this.ensureProviderRegistration();86}87}8889dispose() {90this.providerDisposable?.dispose();91this.disposable.dispose();92}9394async provideTimeline(uri: Uri, options: TimelineOptions, token: CancellationToken): Promise<Timeline> {95// console.log(`GitTimelineProvider.provideTimeline: uri=${uri}`);9697const repo = this.model.getRepository(uri);98if (!repo) {99this.repoDisposable?.dispose();100this.repoOperationDate = undefined;101this.repo = undefined;102103return { items: [] };104}105106if (this.repo?.root !== repo.root) {107this.repoDisposable?.dispose();108109this.repo = repo;110this.repoOperationDate = new Date();111this.repoDisposable = Disposable.from(112repo.onDidChangeRepository(uri => this.onRepositoryChanged(repo, uri)),113repo.onDidRunGitStatus(() => this.onRepositoryStatusChanged(repo)),114repo.onDidRunOperation(result => this.onRepositoryOperationRun(repo, result))115);116}117118// TODO@eamodio: Ensure that the uri is a file -- if not we could get the history of the repo?119120let limit: number | undefined;121if (options.limit !== undefined && typeof options.limit !== 'number') {122try {123const result = await this.model.git.exec(repo.root, ['rev-list', '--count', `${options.limit.id}..`, '--', uri.fsPath]);124if (!result.exitCode) {125// Ask for 2 more (1 for the limit commit and 1 for the next commit) than so we can determine if there are more commits126limit = Number(result.stdout) + 2;127}128}129catch {130limit = undefined;131}132} else {133// If we are not getting everything, ask for 1 more than so we can determine if there are more commits134limit = options.limit === undefined ? undefined : options.limit + 1;135}136137await ensureEmojis();138139const commits = await repo.logFile(140uri,141{142maxEntries: limit,143hash: options.cursor,144follow: true,145shortStats: true,146// sortByAuthorDate: true147},148token149);150151const paging = commits.length ? {152cursor: limit === undefined ? undefined : (commits.length >= limit ? commits[commits.length - 1]?.hash : undefined)153} : undefined;154155// If we asked for an extra commit, strip it off156if (limit !== undefined && commits.length >= limit) {157commits.splice(commits.length - 1, 1);158}159160const config = workspace.getConfiguration('git', Uri.file(repo.root));161const dateType = config.get<'committed' | 'authored'>('timeline.date');162const showAuthor = config.get<boolean>('timeline.showAuthor');163const showUncommitted = config.get<boolean>('timeline.showUncommitted');164165const openComparison = l10n.t('Open Comparison');166167const emptyTree = await repo.getEmptyTree();168const unpublishedCommits = await repo.getUnpublishedCommits();169const remoteHoverCommands = await provideSourceControlHistoryItemHoverCommands(this.model, repo);170171const avatarQuery = {172commits: commits.map(c => ({173hash: c.hash,174authorName: c.authorName,175authorEmail: c.authorEmail176}) satisfies AvatarQueryCommit),177size: 20178} satisfies AvatarQuery;179const avatars = await provideSourceControlHistoryItemAvatar(this.model, repo, avatarQuery);180181const items: GitTimelineItem[] = [];182for (let index = 0; index < commits.length; index++) {183const c = commits[index];184185const date = dateType === 'authored' ? c.authorDate : c.commitDate;186187const message = emojify(c.message);188189const previousRef = commits[index + 1]?.hash ?? emptyTree;190const item = new GitTimelineItem(c.hash, previousRef, message, date?.getTime() ?? 0, c.hash, 'git:file:commit');191item.iconPath = new ThemeIcon('git-commit');192if (showAuthor) {193item.description = c.authorName;194}195196const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteHoverCommands ?? [] : [];197const messageWithLinks = await provideSourceControlHistoryItemMessageLinks(this.model, repo, message) ?? message;198199const commands: Command[][] = [200getHoverCommitHashCommands(uri, c.hash),201processHoverRemoteCommands(commitRemoteSourceCommands, c.hash)202];203204item.tooltip = getCommitHover(avatars?.get(c.hash), c.authorName, c.authorEmail, date, messageWithLinks, c.shortStat, commands);205206const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);207if (cmd) {208item.command = {209title: openComparison,210command: cmd.command,211arguments: cmd.arguments,212};213}214215items.push(item);216}217218if (options.cursor === undefined) {219const you = l10n.t('You');220221const index = repo.indexGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);222if (index) {223const date = this.repoOperationDate ?? new Date();224225const item = new GitTimelineItem('~', 'HEAD', l10n.t('Staged Changes'), date.getTime(), 'index', 'git:file:index');226// TODO@eamodio: Replace with a better icon -- reflecting its status maybe?227item.iconPath = new ThemeIcon('git-commit');228item.description = '';229item.tooltip = getCommitHover(undefined, you, undefined, date, Resource.getStatusText(index.type), undefined, undefined);230231const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);232if (cmd) {233item.command = {234title: openComparison,235command: cmd.command,236arguments: cmd.arguments,237};238}239240items.splice(0, 0, item);241}242243if (showUncommitted) {244const working = repo.workingTreeGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);245if (working) {246const date = new Date();247248const item = new GitTimelineItem('', index ? '~' : 'HEAD', l10n.t('Uncommitted Changes'), date.getTime(), 'working', 'git:file:working');249item.iconPath = new ThemeIcon('circle-outline');250item.description = '';251item.tooltip = getCommitHover(undefined, you, undefined, date, Resource.getStatusText(working.type), undefined, undefined);252253const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);254if (cmd) {255item.command = {256title: openComparison,257command: cmd.command,258arguments: cmd.arguments,259};260}261262items.splice(0, 0, item);263}264}265}266267return {268items: items,269paging: paging270};271}272273private ensureProviderRegistration() {274if (this.providerDisposable === undefined) {275this.providerDisposable = workspace.registerTimelineProvider(['file', 'git', 'vscode-remote', 'vscode-local-history'], this);276}277}278279private onConfigurationChanged(e: ConfigurationChangeEvent) {280if (e.affectsConfiguration('git.timeline.date') || e.affectsConfiguration('git.timeline.showAuthor') || e.affectsConfiguration('git.timeline.showUncommitted')) {281this.fireChanged();282}283}284285private onRepositoriesChanged(_repo: Repository) {286// console.log(`GitTimelineProvider.onRepositoriesChanged`);287288this.ensureProviderRegistration();289290// TODO@eamodio: Being naive for now and just always refreshing each time there is a new repository291this.fireChanged();292}293294private onRepositoryChanged(_repo: Repository, _uri: Uri) {295// console.log(`GitTimelineProvider.onRepositoryChanged: uri=${uri.toString(true)}`);296297this.fireChanged();298}299300private onRepositoryStatusChanged(_repo: Repository) {301// console.log(`GitTimelineProvider.onRepositoryStatusChanged`);302303const config = workspace.getConfiguration('git.timeline');304const showUncommitted = config.get<boolean>('showUncommitted') === true;305306if (showUncommitted) {307this.fireChanged();308}309}310311private onRepositoryOperationRun(_repo: Repository, _result: OperationResult) {312// console.log(`GitTimelineProvider.onRepositoryOperationRun`);313314// Successful operations that are not read-only and not status operations315if (!_result.error && !_result.operation.readOnly && _result.operation.kind !== OperationKind.Status) {316// This is less than ideal, but for now just save the last time an317// operation was run and use that as the timestamp for staged items318this.repoOperationDate = new Date();319320this.fireChanged();321}322}323324@debounce(500)325private fireChanged() {326this._onDidChange.fire(undefined);327}328}329330331