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