Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/git/src/timelineProvider.ts
3316 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken, ConfigurationChangeEvent, Disposable, env, Event, EventEmitter, MarkdownString, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace, l10n, Command } from 'vscode';
7
import { Model } from './model';
8
import { Repository, Resource } from './repository';
9
import { debounce } from './decorators';
10
import { emojify, ensureEmojis } from './emoji';
11
import { CommandCenter } from './commands';
12
import { OperationKind, OperationResult } from './operation';
13
import { truncate } from './util';
14
import { CommitShortStat } from './git';
15
import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';
16
import { AvatarQuery, AvatarQueryCommit } from './api/git';
17
18
const AVATAR_SIZE = 20;
19
20
export class GitTimelineItem extends TimelineItem {
21
static is(item: TimelineItem): item is GitTimelineItem {
22
return item instanceof GitTimelineItem;
23
}
24
25
readonly ref: string;
26
readonly previousRef: string;
27
readonly message: string;
28
29
constructor(
30
ref: string,
31
previousRef: string,
32
message: string,
33
timestamp: number,
34
id: string,
35
contextValue: string
36
) {
37
const index = message.indexOf('\n');
38
const label = index !== -1 ? `${truncate(message, index, false)}` : message;
39
40
super(label, timestamp);
41
42
this.ref = ref;
43
this.previousRef = previousRef;
44
this.message = message;
45
this.id = id;
46
this.contextValue = contextValue;
47
}
48
49
get shortRef() {
50
return this.shortenRef(this.ref);
51
}
52
53
get shortPreviousRef() {
54
return this.shortenRef(this.previousRef);
55
}
56
57
setItemDetails(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 {
58
this.tooltip = new MarkdownString('', true);
59
this.tooltip.isTrusted = true;
60
61
const avatarMarkdown = avatar
62
? `![${author}](${avatar}|width=${AVATAR_SIZE},height=${AVATAR_SIZE})`
63
: '$(account)';
64
65
if (email) {
66
const emailTitle = l10n.t('Email');
67
this.tooltip.appendMarkdown(`${avatarMarkdown} [**${author}**](mailto:${email} "${emailTitle} ${author}")`);
68
} else {
69
this.tooltip.appendMarkdown(`${avatarMarkdown} **${author}**`);
70
}
71
72
this.tooltip.appendMarkdown(`, $(history) ${date}\n\n`);
73
this.tooltip.appendMarkdown(`${message}\n\n`);
74
75
if (shortStat) {
76
this.tooltip.appendMarkdown(`---\n\n`);
77
78
const labels: string[] = [];
79
if (shortStat.insertions) {
80
labels.push(`<span style="color:var(--vscode-scmGraph-historyItemHoverAdditionsForeground);">${shortStat.insertions === 1 ?
81
l10n.t('{0} insertion{1}', shortStat.insertions, '(+)') :
82
l10n.t('{0} insertions{1}', shortStat.insertions, '(+)')}</span>`);
83
}
84
85
if (shortStat.deletions) {
86
labels.push(`<span style="color:var(--vscode-scmGraph-historyItemHoverDeletionsForeground);">${shortStat.deletions === 1 ?
87
l10n.t('{0} deletion{1}', shortStat.deletions, '(-)') :
88
l10n.t('{0} deletions{1}', shortStat.deletions, '(-)')}</span>`);
89
}
90
91
this.tooltip.appendMarkdown(`${labels.join(', ')}\n\n`);
92
}
93
94
if (hash && shortHash) {
95
this.tooltip.appendMarkdown(`---\n\n`);
96
97
this.tooltip.appendMarkdown(`[\`$(git-commit) ${shortHash} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([uri, hash, uri]))} "${l10n.t('Open Commit')}")`);
98
this.tooltip.appendMarkdown('&nbsp;');
99
this.tooltip.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`);
100
101
// Remote commands
102
if (remoteSourceCommands.length > 0) {
103
this.tooltip.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');
104
105
const remoteCommandsMarkdown = remoteSourceCommands
106
.map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`);
107
this.tooltip.appendMarkdown(remoteCommandsMarkdown.join('&nbsp;'));
108
}
109
}
110
}
111
112
private shortenRef(ref: string): string {
113
if (ref === '' || ref === '~' || ref === 'HEAD') {
114
return ref;
115
}
116
return ref.endsWith('^') ? `${ref.substr(0, 8)}^` : ref.substr(0, 8);
117
}
118
}
119
120
export class GitTimelineProvider implements TimelineProvider {
121
private _onDidChange = new EventEmitter<TimelineChangeEvent | undefined>();
122
get onDidChange(): Event<TimelineChangeEvent | undefined> {
123
return this._onDidChange.event;
124
}
125
126
readonly id = 'git-history';
127
readonly label = l10n.t('Git History');
128
129
private readonly disposable: Disposable;
130
private providerDisposable: Disposable | undefined;
131
132
private repo: Repository | undefined;
133
private repoDisposable: Disposable | undefined;
134
private repoOperationDate: Date | undefined;
135
136
constructor(private readonly model: Model, private commands: CommandCenter) {
137
this.disposable = Disposable.from(
138
model.onDidOpenRepository(this.onRepositoriesChanged, this),
139
workspace.onDidChangeConfiguration(this.onConfigurationChanged, this)
140
);
141
142
if (model.repositories.length) {
143
this.ensureProviderRegistration();
144
}
145
}
146
147
dispose() {
148
this.providerDisposable?.dispose();
149
this.disposable.dispose();
150
}
151
152
async provideTimeline(uri: Uri, options: TimelineOptions, token: CancellationToken): Promise<Timeline> {
153
// console.log(`GitTimelineProvider.provideTimeline: uri=${uri}`);
154
155
const repo = this.model.getRepository(uri);
156
if (!repo) {
157
this.repoDisposable?.dispose();
158
this.repoOperationDate = undefined;
159
this.repo = undefined;
160
161
return { items: [] };
162
}
163
164
if (this.repo?.root !== repo.root) {
165
this.repoDisposable?.dispose();
166
167
this.repo = repo;
168
this.repoOperationDate = new Date();
169
this.repoDisposable = Disposable.from(
170
repo.onDidChangeRepository(uri => this.onRepositoryChanged(repo, uri)),
171
repo.onDidRunGitStatus(() => this.onRepositoryStatusChanged(repo)),
172
repo.onDidRunOperation(result => this.onRepositoryOperationRun(repo, result))
173
);
174
}
175
176
// TODO@eamodio: Ensure that the uri is a file -- if not we could get the history of the repo?
177
178
let limit: number | undefined;
179
if (options.limit !== undefined && typeof options.limit !== 'number') {
180
try {
181
const result = await this.model.git.exec(repo.root, ['rev-list', '--count', `${options.limit.id}..`, '--', uri.fsPath]);
182
if (!result.exitCode) {
183
// Ask for 2 more (1 for the limit commit and 1 for the next commit) than so we can determine if there are more commits
184
limit = Number(result.stdout) + 2;
185
}
186
}
187
catch {
188
limit = undefined;
189
}
190
} else {
191
// If we are not getting everything, ask for 1 more than so we can determine if there are more commits
192
limit = options.limit === undefined ? undefined : options.limit + 1;
193
}
194
195
await ensureEmojis();
196
197
const commits = await repo.logFile(
198
uri,
199
{
200
maxEntries: limit,
201
hash: options.cursor,
202
follow: true,
203
shortStats: true,
204
// sortByAuthorDate: true
205
},
206
token
207
);
208
209
const paging = commits.length ? {
210
cursor: limit === undefined ? undefined : (commits.length >= limit ? commits[commits.length - 1]?.hash : undefined)
211
} : undefined;
212
213
// If we asked for an extra commit, strip it off
214
if (limit !== undefined && commits.length >= limit) {
215
commits.splice(commits.length - 1, 1);
216
}
217
218
const dateFormatter = new Intl.DateTimeFormat(env.language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
219
220
const config = workspace.getConfiguration('git', Uri.file(repo.root));
221
const dateType = config.get<'committed' | 'authored'>('timeline.date');
222
const showAuthor = config.get<boolean>('timeline.showAuthor');
223
const showUncommitted = config.get<boolean>('timeline.showUncommitted');
224
const commitShortHashLength = config.get<number>('commitShortHashLength') ?? 7;
225
226
const openComparison = l10n.t('Open Comparison');
227
228
const emptyTree = await repo.getEmptyTree();
229
const unpublishedCommits = await repo.getUnpublishedCommits();
230
const remoteHoverCommands = await provideSourceControlHistoryItemHoverCommands(this.model, repo);
231
232
const avatarQuery = {
233
commits: commits.map(c => ({
234
hash: c.hash,
235
authorName: c.authorName,
236
authorEmail: c.authorEmail
237
}) satisfies AvatarQueryCommit),
238
size: 20
239
} satisfies AvatarQuery;
240
const avatars = await provideSourceControlHistoryItemAvatar(this.model, repo, avatarQuery);
241
242
const items: GitTimelineItem[] = [];
243
for (let index = 0; index < commits.length; index++) {
244
const c = commits[index];
245
246
const date = dateType === 'authored' ? c.authorDate : c.commitDate;
247
248
const message = emojify(c.message);
249
250
const previousRef = commits[index + 1]?.hash ?? emptyTree;
251
const item = new GitTimelineItem(c.hash, previousRef, message, date?.getTime() ?? 0, c.hash, 'git:file:commit');
252
item.iconPath = new ThemeIcon('git-commit');
253
if (showAuthor) {
254
item.description = c.authorName;
255
}
256
257
const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteHoverCommands : [];
258
const messageWithLinks = await provideSourceControlHistoryItemMessageLinks(this.model, repo, message) ?? message;
259
260
item.setItemDetails(uri, c.hash, truncate(c.hash, commitShortHashLength, false), avatars?.get(c.hash), c.authorName!, c.authorEmail, dateFormatter.format(date), messageWithLinks, c.shortStat, commitRemoteSourceCommands);
261
262
const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);
263
if (cmd) {
264
item.command = {
265
title: openComparison,
266
command: cmd.command,
267
arguments: cmd.arguments,
268
};
269
}
270
271
items.push(item);
272
}
273
274
if (options.cursor === undefined) {
275
const you = l10n.t('You');
276
277
const index = repo.indexGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
278
if (index) {
279
const date = this.repoOperationDate ?? new Date();
280
281
const item = new GitTimelineItem('~', 'HEAD', l10n.t('Staged Changes'), date.getTime(), 'index', 'git:file:index');
282
// TODO@eamodio: Replace with a better icon -- reflecting its status maybe?
283
item.iconPath = new ThemeIcon('git-commit');
284
item.description = '';
285
item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type));
286
287
const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);
288
if (cmd) {
289
item.command = {
290
title: openComparison,
291
command: cmd.command,
292
arguments: cmd.arguments,
293
};
294
}
295
296
items.splice(0, 0, item);
297
}
298
299
if (showUncommitted) {
300
const working = repo.workingTreeGroup.resourceStates.find(r => r.resourceUri.fsPath === uri.fsPath);
301
if (working) {
302
const date = new Date();
303
304
const item = new GitTimelineItem('', index ? '~' : 'HEAD', l10n.t('Uncommitted Changes'), date.getTime(), 'working', 'git:file:working');
305
item.iconPath = new ThemeIcon('circle-outline');
306
item.description = '';
307
item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type));
308
309
const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);
310
if (cmd) {
311
item.command = {
312
title: openComparison,
313
command: cmd.command,
314
arguments: cmd.arguments,
315
};
316
}
317
318
items.splice(0, 0, item);
319
}
320
}
321
}
322
323
return {
324
items: items,
325
paging: paging
326
};
327
}
328
329
private ensureProviderRegistration() {
330
if (this.providerDisposable === undefined) {
331
this.providerDisposable = workspace.registerTimelineProvider(['file', 'git', 'vscode-remote', 'vscode-local-history'], this);
332
}
333
}
334
335
private onConfigurationChanged(e: ConfigurationChangeEvent) {
336
if (e.affectsConfiguration('git.timeline.date') || e.affectsConfiguration('git.timeline.showAuthor') || e.affectsConfiguration('git.timeline.showUncommitted')) {
337
this.fireChanged();
338
}
339
}
340
341
private onRepositoriesChanged(_repo: Repository) {
342
// console.log(`GitTimelineProvider.onRepositoriesChanged`);
343
344
this.ensureProviderRegistration();
345
346
// TODO@eamodio: Being naive for now and just always refreshing each time there is a new repository
347
this.fireChanged();
348
}
349
350
private onRepositoryChanged(_repo: Repository, _uri: Uri) {
351
// console.log(`GitTimelineProvider.onRepositoryChanged: uri=${uri.toString(true)}`);
352
353
this.fireChanged();
354
}
355
356
private onRepositoryStatusChanged(_repo: Repository) {
357
// console.log(`GitTimelineProvider.onRepositoryStatusChanged`);
358
359
const config = workspace.getConfiguration('git.timeline');
360
const showUncommitted = config.get<boolean>('showUncommitted') === true;
361
362
if (showUncommitted) {
363
this.fireChanged();
364
}
365
}
366
367
private onRepositoryOperationRun(_repo: Repository, _result: OperationResult) {
368
// console.log(`GitTimelineProvider.onRepositoryOperationRun`);
369
370
// Successful operations that are not read-only and not status operations
371
if (!_result.error && !_result.operation.readOnly && _result.operation.kind !== OperationKind.Status) {
372
// This is less than ideal, but for now just save the last time an
373
// operation was run and use that as the timestamp for staged items
374
this.repoOperationDate = new Date();
375
376
this.fireChanged();
377
}
378
}
379
380
@debounce(500)
381
private fireChanged() {
382
this._onDidChange.fire(undefined);
383
}
384
}
385
386