mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-12 22:08:04 -05:00
UI: Adds Refresh icon to view title Adds "Load more" entry at the end of the list for paging API: Restructures api around cursors Renames TimelineCursor to generic TimelineOptions for more flexibility Adds paging object to Timeline for clearer paging usage Changes cursors to be strings, and explicit before and after cursors Allows limit to take a cursor, so we can reload current data set Clarifies id and fallback to timestamp Adds reset flag to TimelineChangeEvent for providers to reset caching Git provider: Orders and returns commit date as the timestamp Supports limit of a cursor (using rev-list --count) Stops returning working/index changes when paging Forcably resets cached data when changes are detected (naive for now)
290 lines
8.4 KiB
TypeScript
290 lines
8.4 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import * as dayjs from 'dayjs';
|
|
import * as advancedFormat from 'dayjs/plugin/advancedFormat';
|
|
import { CancellationToken, Disposable, Event, EventEmitter, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace } from 'vscode';
|
|
import { Model } from './model';
|
|
import { Repository } from './repository';
|
|
import { debounce } from './decorators';
|
|
import { Status } from './api/git';
|
|
|
|
dayjs.extend(advancedFormat);
|
|
|
|
// TODO[ECA]: Localize all the strings
|
|
// TODO[ECA]: Localize or use a setting for date format
|
|
|
|
export class GitTimelineItem extends TimelineItem {
|
|
static is(item: TimelineItem): item is GitTimelineItem {
|
|
return item instanceof GitTimelineItem;
|
|
}
|
|
|
|
readonly ref: string;
|
|
readonly previousRef: string;
|
|
readonly message: string;
|
|
|
|
constructor(
|
|
ref: string,
|
|
previousRef: string,
|
|
message: string,
|
|
timestamp: number,
|
|
id: string,
|
|
contextValue: string
|
|
) {
|
|
const index = message.indexOf('\n');
|
|
const label = index !== -1 ? `${message.substring(0, index)} \u2026` : message;
|
|
|
|
super(label, timestamp);
|
|
|
|
this.ref = ref;
|
|
this.previousRef = previousRef;
|
|
this.message = message;
|
|
this.id = id;
|
|
this.contextValue = contextValue;
|
|
}
|
|
|
|
get shortRef() {
|
|
return this.shortenRef(this.ref);
|
|
}
|
|
|
|
get shortPreviousRef() {
|
|
return this.shortenRef(this.previousRef);
|
|
}
|
|
|
|
private shortenRef(ref: string): string {
|
|
if (ref === '' || ref === '~' || ref === 'HEAD') {
|
|
return ref;
|
|
}
|
|
return ref.endsWith('^') ? `${ref.substr(0, 8)}^` : ref.substr(0, 8);
|
|
}
|
|
}
|
|
|
|
export class GitTimelineProvider implements TimelineProvider {
|
|
private _onDidChange = new EventEmitter<TimelineChangeEvent>();
|
|
get onDidChange(): Event<TimelineChangeEvent> {
|
|
return this._onDidChange.event;
|
|
}
|
|
|
|
readonly id = 'git-history';
|
|
readonly label = 'Git History';
|
|
|
|
private _disposable: Disposable;
|
|
|
|
private _repo: Repository | undefined;
|
|
private _repoDisposable: Disposable | undefined;
|
|
private _repoStatusDate: Date | undefined;
|
|
|
|
constructor(private readonly _model: Model) {
|
|
this._disposable = Disposable.from(
|
|
_model.onDidOpenRepository(this.onRepositoriesChanged, this),
|
|
workspace.registerTimelineProvider('*', this),
|
|
);
|
|
}
|
|
|
|
dispose() {
|
|
this._disposable.dispose();
|
|
}
|
|
|
|
async provideTimeline(uri: Uri, options: TimelineOptions, _token: CancellationToken): Promise<Timeline> {
|
|
// console.log(`GitTimelineProvider.provideTimeline: uri=${uri} state=${this._model.state}`);
|
|
|
|
const repo = this._model.getRepository(uri);
|
|
if (!repo) {
|
|
this._repoDisposable?.dispose();
|
|
this._repoStatusDate = undefined;
|
|
this._repo = undefined;
|
|
|
|
return { items: [] };
|
|
}
|
|
|
|
if (this._repo?.root !== repo.root) {
|
|
this._repoDisposable?.dispose();
|
|
|
|
this._repo = repo;
|
|
this._repoStatusDate = new Date();
|
|
this._repoDisposable = Disposable.from(
|
|
repo.onDidChangeRepository(uri => this.onRepositoryChanged(repo, uri)),
|
|
repo.onDidRunGitStatus(() => this.onRepositoryStatusChanged(repo))
|
|
);
|
|
}
|
|
|
|
// TODO[ECA]: Ensure that the uri is a file -- if not we could get the history of the repo?
|
|
|
|
let limit: number | undefined;
|
|
if (typeof options.limit === 'string') {
|
|
try {
|
|
const result = await this._model.git.exec(repo.root, ['rev-list', '--count', `${options.limit}..`, '--', uri.fsPath]);
|
|
if (!result.exitCode) {
|
|
// Ask for 1 more than so we can determine if there are more commits
|
|
limit = Number(result.stdout) + 1;
|
|
}
|
|
}
|
|
catch {
|
|
limit = undefined;
|
|
}
|
|
} else {
|
|
// If we are not getting everything, ask for 1 more than so we can determine if there are more commits
|
|
limit = options.limit === undefined ? undefined : options.limit + 1;
|
|
}
|
|
|
|
|
|
const commits = await repo.logFile(uri, {
|
|
maxEntries: limit,
|
|
hash: options.cursor,
|
|
reverse: options.before,
|
|
// sortByAuthorDate: true
|
|
});
|
|
|
|
const more = limit === undefined || options.before ? false : commits.length >= limit;
|
|
const paging = commits.length ? {
|
|
more: more,
|
|
cursors: {
|
|
before: commits[0]?.hash,
|
|
after: commits[commits.length - (more ? 1 : 2)]?.hash
|
|
}
|
|
} : undefined;
|
|
|
|
// If we asked for an extra commit, strip it off
|
|
if (limit !== undefined && commits.length >= limit) {
|
|
commits.splice(commits.length - 1, 1);
|
|
}
|
|
|
|
let dateFormatter: dayjs.Dayjs;
|
|
const items = commits.map<GitTimelineItem>(c => {
|
|
const date = c.commitDate; // c.authorDate
|
|
|
|
dateFormatter = dayjs(date);
|
|
|
|
const item = new GitTimelineItem(c.hash, `${c.hash}^`, c.message, date?.getTime() ?? 0, c.hash, 'git:file:commit');
|
|
item.iconPath = new (ThemeIcon as any)('git-commit');
|
|
item.description = c.authorName;
|
|
item.detail = `${c.authorName} (${c.authorEmail}) \u2014 ${c.hash.substr(0, 8)}\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n\n${c.message}`;
|
|
item.command = {
|
|
title: 'Open Comparison',
|
|
command: 'git.timeline.openDiff',
|
|
arguments: [item, uri, this.id]
|
|
};
|
|
|
|
return item;
|
|
});
|
|
|
|
if (options.cursor === undefined || options.before) {
|
|
const index = repo.indexGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
|
|
if (index) {
|
|
const date = this._repoStatusDate ?? new Date();
|
|
dateFormatter = dayjs(date);
|
|
|
|
let status;
|
|
switch (index.type) {
|
|
case Status.INDEX_MODIFIED:
|
|
status = 'Modified';
|
|
break;
|
|
case Status.INDEX_ADDED:
|
|
status = 'Added';
|
|
break;
|
|
case Status.INDEX_DELETED:
|
|
status = 'Deleted';
|
|
break;
|
|
case Status.INDEX_RENAMED:
|
|
status = 'Renamed';
|
|
break;
|
|
case Status.INDEX_COPIED:
|
|
status = 'Copied';
|
|
break;
|
|
default:
|
|
status = '';
|
|
break;
|
|
}
|
|
|
|
const item = new GitTimelineItem('~', 'HEAD', 'Staged Changes', date.getTime(), 'index', 'git:file:index');
|
|
// TODO[ECA]: Replace with a better icon -- reflecting its status maybe?
|
|
item.iconPath = new (ThemeIcon as any)('git-commit');
|
|
item.description = 'You';
|
|
item.detail = `You \u2014 Index\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n${status}`;
|
|
item.command = {
|
|
title: 'Open Comparison',
|
|
command: 'git.timeline.openDiff',
|
|
arguments: [item, uri, this.id]
|
|
};
|
|
|
|
items.splice(0, 0, item);
|
|
}
|
|
|
|
const working = repo.workingTreeGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
|
|
if (working) {
|
|
const date = new Date();
|
|
dateFormatter = dayjs(date);
|
|
|
|
let status;
|
|
switch (working.type) {
|
|
case Status.INDEX_MODIFIED:
|
|
status = 'Modified';
|
|
break;
|
|
case Status.INDEX_ADDED:
|
|
status = 'Added';
|
|
break;
|
|
case Status.INDEX_DELETED:
|
|
status = 'Deleted';
|
|
break;
|
|
case Status.INDEX_RENAMED:
|
|
status = 'Renamed';
|
|
break;
|
|
case Status.INDEX_COPIED:
|
|
status = 'Copied';
|
|
break;
|
|
default:
|
|
status = '';
|
|
break;
|
|
}
|
|
|
|
const item = new GitTimelineItem('', index ? '~' : 'HEAD', 'Uncommited Changes', date.getTime(), 'working', 'git:file:working');
|
|
// TODO[ECA]: Replace with a better icon -- reflecting its status maybe?
|
|
item.iconPath = new (ThemeIcon as any)('git-commit');
|
|
item.description = 'You';
|
|
item.detail = `You \u2014 Working Tree\n${dateFormatter.format('MMMM Do, YYYY h:mma')}\n${status}`;
|
|
item.command = {
|
|
title: 'Open Comparison',
|
|
command: 'git.timeline.openDiff',
|
|
arguments: [item, uri, this.id]
|
|
};
|
|
|
|
items.splice(0, 0, item);
|
|
}
|
|
}
|
|
|
|
return {
|
|
items: items,
|
|
paging: paging
|
|
};
|
|
}
|
|
|
|
private onRepositoriesChanged(_repo: Repository) {
|
|
// console.log(`GitTimelineProvider.onRepositoriesChanged`);
|
|
|
|
// TODO[ECA]: Being naive for now and just always refreshing each time there is a new repository
|
|
this.fireChanged();
|
|
}
|
|
|
|
private onRepositoryChanged(_repo: Repository, _uri: Uri) {
|
|
// console.log(`GitTimelineProvider.onRepositoryChanged: uri=${uri.toString(true)}`);
|
|
|
|
this.fireChanged();
|
|
}
|
|
|
|
private onRepositoryStatusChanged(_repo: Repository) {
|
|
// console.log(`GitTimelineProvider.onRepositoryStatusChanged`);
|
|
|
|
// This is crappy, but for now just save the last time a status was run and use that as the timestamp for staged items
|
|
this._repoStatusDate = new Date();
|
|
|
|
this.fireChanged();
|
|
}
|
|
|
|
@debounce(500)
|
|
private fireChanged() {
|
|
this._onDidChange.fire({ reset: true });
|
|
}
|
|
}
|