@theia/scm-extra 1.53.0-next.5 → 1.53.0-next.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -32
- package/package.json +8 -8
- package/src/browser/history/index.ts +21 -21
- package/src/browser/history/scm-history-constants.ts +69 -69
- package/src/browser/history/scm-history-contribution.ts +90 -90
- package/src/browser/history/scm-history-frontend-module.ts +36 -36
- package/src/browser/history/scm-history-provider.ts +27 -27
- package/src/browser/history/scm-history-widget.tsx +571 -571
- package/src/browser/scm-extra-contribution.ts +18 -18
- package/src/browser/scm-extra-frontend-module.ts +27 -27
- package/src/browser/scm-extra-layout-migrations.ts +32 -32
- package/src/browser/scm-file-change-label-provider.ts +73 -73
- package/src/browser/scm-file-change-node.ts +45 -45
- package/src/browser/scm-navigable-list-widget.tsx +197 -197
- package/src/browser/style/history.css +164 -164
|
@@ -1,571 +1,571 @@
|
|
|
1
|
-
// *****************************************************************************
|
|
2
|
-
// Copyright (C) 2018 TypeFox and others.
|
|
3
|
-
//
|
|
4
|
-
// This program and the accompanying materials are made available under the
|
|
5
|
-
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
-
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
-
//
|
|
8
|
-
// This Source Code may also be made available under the following Secondary
|
|
9
|
-
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
-
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
-
// with the GNU Classpath Exception which is available at
|
|
12
|
-
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
-
//
|
|
14
|
-
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
-
// *****************************************************************************
|
|
16
|
-
|
|
17
|
-
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
|
|
18
|
-
import { DisposableCollection } from '@theia/core';
|
|
19
|
-
import { OpenerService, open, StatefulWidget, SELECTED_CLASS, WidgetManager, ApplicationShell, codicon } from '@theia/core/lib/browser';
|
|
20
|
-
import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
|
|
21
|
-
import { Message } from '@theia/core/shared/@phosphor/messaging';
|
|
22
|
-
import { Virtuoso, VirtuosoHandle } from '@theia/core/shared/react-virtuoso';
|
|
23
|
-
import URI from '@theia/core/lib/common/uri';
|
|
24
|
-
import { ScmFileChange, ScmFileChangeNode } from '../scm-file-change-node';
|
|
25
|
-
import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service';
|
|
26
|
-
import { ScmItemComponent, ScmNavigableListWidget } from '../scm-navigable-list-widget';
|
|
27
|
-
import * as React from '@theia/core/shared/react';
|
|
28
|
-
import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message';
|
|
29
|
-
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
|
30
|
-
import { nls } from '@theia/core/lib/common/nls';
|
|
31
|
-
import { ScmHistoryProvider } from './scm-history-provider';
|
|
32
|
-
import throttle = require('@theia/core/shared/lodash.throttle');
|
|
33
|
-
import { HistoryWidgetOptions, ScmCommitNode, ScmHistoryListNode, ScmHistorySupport, SCM_HISTORY_ID, SCM_HISTORY_LABEL, SCM_HISTORY_MAX_COUNT } from './scm-history-constants';
|
|
34
|
-
export { HistoryWidgetOptions, ScmCommitNode, ScmHistoryListNode, ScmHistorySupport };
|
|
35
|
-
|
|
36
|
-
@injectable()
|
|
37
|
-
export class ScmHistoryWidget extends ScmNavigableListWidget<ScmHistoryListNode> implements StatefulWidget {
|
|
38
|
-
protected options: HistoryWidgetOptions;
|
|
39
|
-
protected singleFileMode: boolean;
|
|
40
|
-
private cancelIndicator: CancellationTokenSource;
|
|
41
|
-
protected listView: ScmHistoryList | undefined;
|
|
42
|
-
protected hasMoreCommits: boolean;
|
|
43
|
-
protected allowScrollToSelected: boolean;
|
|
44
|
-
|
|
45
|
-
protected status: {
|
|
46
|
-
state: 'loading',
|
|
47
|
-
} | {
|
|
48
|
-
state: 'ready',
|
|
49
|
-
commits: ScmCommitNode[];
|
|
50
|
-
} | {
|
|
51
|
-
state: 'error',
|
|
52
|
-
errorMessage: React.ReactNode
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
protected readonly toDisposeOnRepositoryChange = new DisposableCollection();
|
|
56
|
-
|
|
57
|
-
protected historySupport: ScmHistorySupport | undefined;
|
|
58
|
-
|
|
59
|
-
constructor(
|
|
60
|
-
@inject(OpenerService) protected readonly openerService: OpenerService,
|
|
61
|
-
@inject(ApplicationShell) protected readonly shell: ApplicationShell,
|
|
62
|
-
@inject(FileService) protected readonly fileService: FileService,
|
|
63
|
-
@inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService,
|
|
64
|
-
@inject(WidgetManager) protected readonly widgetManager: WidgetManager,
|
|
65
|
-
) {
|
|
66
|
-
super();
|
|
67
|
-
this.id = SCM_HISTORY_ID;
|
|
68
|
-
this.scrollContainer = 'scm-history-list-container';
|
|
69
|
-
this.title.label = SCM_HISTORY_LABEL;
|
|
70
|
-
this.title.caption = SCM_HISTORY_LABEL;
|
|
71
|
-
this.title.iconClass = codicon('history');
|
|
72
|
-
this.title.closable = true;
|
|
73
|
-
this.addClass('theia-scm');
|
|
74
|
-
this.addClass('theia-scm-history');
|
|
75
|
-
this.status = { state: 'loading' };
|
|
76
|
-
this.resetState();
|
|
77
|
-
this.cancelIndicator = new CancellationTokenSource();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
@postConstruct()
|
|
81
|
-
protected init(): void {
|
|
82
|
-
this.refreshOnRepositoryChange();
|
|
83
|
-
this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => this.refreshOnRepositoryChange()));
|
|
84
|
-
this.toDispose.push(this.labelProvider.onDidChange(event => {
|
|
85
|
-
if (this.scmNodes.some(node => ScmFileChangeNode.is(node) && event.affects(new URI(node.fileChange.uri)))) {
|
|
86
|
-
this.update();
|
|
87
|
-
}
|
|
88
|
-
}));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
protected refreshOnRepositoryChange(): void {
|
|
92
|
-
this.toDisposeOnRepositoryChange.dispose();
|
|
93
|
-
|
|
94
|
-
const repository = this.scmService.selectedRepository;
|
|
95
|
-
if (repository && ScmHistoryProvider.is(repository.provider)) {
|
|
96
|
-
this.historySupport = repository.provider.historySupport;
|
|
97
|
-
if (this.historySupport) {
|
|
98
|
-
this.toDisposeOnRepositoryChange.push(this.historySupport.onDidChangeHistory(() => this.setContent(this.options)));
|
|
99
|
-
}
|
|
100
|
-
} else {
|
|
101
|
-
this.historySupport = undefined;
|
|
102
|
-
}
|
|
103
|
-
this.setContent(this.options);
|
|
104
|
-
|
|
105
|
-
// If switching repository, discard options because they are specific to a repository
|
|
106
|
-
this.options = this.createHistoryOptions();
|
|
107
|
-
|
|
108
|
-
this.refresh();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
protected createHistoryOptions(): HistoryWidgetOptions {
|
|
112
|
-
return {
|
|
113
|
-
maxCount: SCM_HISTORY_MAX_COUNT
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
protected readonly toDisposeOnRefresh = new DisposableCollection();
|
|
118
|
-
protected refresh(): void {
|
|
119
|
-
this.toDisposeOnRefresh.dispose();
|
|
120
|
-
this.toDispose.push(this.toDisposeOnRefresh);
|
|
121
|
-
const repository = this.scmService.selectedRepository;
|
|
122
|
-
this.title.label = SCM_HISTORY_LABEL;
|
|
123
|
-
if (repository) {
|
|
124
|
-
this.title.label += ': ' + repository.provider.label;
|
|
125
|
-
}
|
|
126
|
-
const area = this.shell.getAreaFor(this);
|
|
127
|
-
if (area === 'left') {
|
|
128
|
-
this.shell.leftPanelHandler.refresh();
|
|
129
|
-
} else if (area === 'right') {
|
|
130
|
-
this.shell.rightPanelHandler.refresh();
|
|
131
|
-
}
|
|
132
|
-
this.update();
|
|
133
|
-
|
|
134
|
-
if (repository) {
|
|
135
|
-
this.toDisposeOnRefresh.push(repository.onDidChange(() => this.update()));
|
|
136
|
-
// render synchronously to avoid cursor jumping
|
|
137
|
-
// see https://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465
|
|
138
|
-
this.toDisposeOnRefresh.push(repository.input.onDidChange(() => this.setContent(this.options)));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
protected override onAfterAttach(msg: Message): void {
|
|
143
|
-
super.onAfterAttach(msg);
|
|
144
|
-
this.addListNavigationKeyListeners(this.node);
|
|
145
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
-
this.addEventListener<any>(this.node, 'ps-scroll-y', (e: Event & { target: { scrollTop: number } }) => {
|
|
147
|
-
if (this.listView?.list) {
|
|
148
|
-
const { scrollTop } = e.target;
|
|
149
|
-
this.listView.list.scrollTo({
|
|
150
|
-
top: scrollTop
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
setContent = throttle((options?: HistoryWidgetOptions) => this.doSetContent(options), 100);
|
|
157
|
-
|
|
158
|
-
protected async doSetContent(options?: HistoryWidgetOptions): Promise<void> {
|
|
159
|
-
this.resetState(options);
|
|
160
|
-
if (options && options.uri) {
|
|
161
|
-
try {
|
|
162
|
-
const fileStat = await this.fileService.resolve(new URI(options.uri));
|
|
163
|
-
this.singleFileMode = !fileStat.isDirectory;
|
|
164
|
-
} catch {
|
|
165
|
-
this.singleFileMode = true;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
await this.addCommits(options);
|
|
169
|
-
this.onDataReady();
|
|
170
|
-
if (this.scmNodes.length > 0) {
|
|
171
|
-
this.selectNode(this.scmNodes[0]);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
protected resetState(options?: HistoryWidgetOptions): void {
|
|
176
|
-
this.options = options || this.createHistoryOptions();
|
|
177
|
-
this.hasMoreCommits = true;
|
|
178
|
-
this.allowScrollToSelected = true;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
protected async addCommits(options?: HistoryWidgetOptions): Promise<void> {
|
|
182
|
-
const repository = this.scmService.selectedRepository;
|
|
183
|
-
|
|
184
|
-
this.cancelIndicator.cancel();
|
|
185
|
-
this.cancelIndicator = new CancellationTokenSource();
|
|
186
|
-
const token = this.cancelIndicator.token;
|
|
187
|
-
|
|
188
|
-
if (repository) {
|
|
189
|
-
if (this.historySupport) {
|
|
190
|
-
try {
|
|
191
|
-
const history = await this.historySupport.getCommitHistory(options);
|
|
192
|
-
if (token.isCancellationRequested || !this.hasMoreCommits) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (options && (options.maxCount && history.length < options.maxCount)) {
|
|
197
|
-
this.hasMoreCommits = false;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const avatarCache = new Map<string, string>();
|
|
201
|
-
|
|
202
|
-
const commits: ScmCommitNode[] = [];
|
|
203
|
-
for (const commit of history) {
|
|
204
|
-
const fileChangeNodes: ScmFileChangeNode[] = [];
|
|
205
|
-
await Promise.all(commit.fileChanges.map(async fileChange => {
|
|
206
|
-
fileChangeNodes.push({
|
|
207
|
-
fileChange, commitId: commit.id
|
|
208
|
-
});
|
|
209
|
-
}));
|
|
210
|
-
|
|
211
|
-
let avatarUrl = '';
|
|
212
|
-
if (avatarCache.has(commit.authorEmail)) {
|
|
213
|
-
avatarUrl = avatarCache.get(commit.authorEmail)!;
|
|
214
|
-
} else {
|
|
215
|
-
avatarUrl = await this.avatarService.getAvatar(commit.authorEmail);
|
|
216
|
-
avatarCache.set(commit.authorEmail, avatarUrl);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
commits.push({
|
|
220
|
-
commitDetails: commit,
|
|
221
|
-
authorAvatar: avatarUrl,
|
|
222
|
-
fileChangeNodes,
|
|
223
|
-
expanded: false,
|
|
224
|
-
selected: false
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
this.status = { state: 'ready', commits };
|
|
228
|
-
} catch (error) {
|
|
229
|
-
if (options && options.uri && repository) {
|
|
230
|
-
this.hasMoreCommits = false;
|
|
231
|
-
}
|
|
232
|
-
this.status = { state: 'error', errorMessage: <React.Fragment> {error.message} </React.Fragment> };
|
|
233
|
-
}
|
|
234
|
-
} else {
|
|
235
|
-
this.status = { state: 'error', errorMessage: <React.Fragment>History is not supported for {repository.provider.label} source control.</React.Fragment> };
|
|
236
|
-
}
|
|
237
|
-
} else {
|
|
238
|
-
this.status = {
|
|
239
|
-
state: 'error',
|
|
240
|
-
errorMessage: <React.Fragment>{nls.localizeByDefault('No source control providers registered.')}</React.Fragment>
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
protected async addOrRemoveFileChangeNodes(commit: ScmCommitNode): Promise<void> {
|
|
246
|
-
const id = this.scmNodes.findIndex(node => node === commit);
|
|
247
|
-
if (commit.expanded) {
|
|
248
|
-
this.removeFileChangeNodes(commit, id);
|
|
249
|
-
} else {
|
|
250
|
-
await this.addFileChangeNodes(commit, id);
|
|
251
|
-
}
|
|
252
|
-
commit.expanded = !commit.expanded;
|
|
253
|
-
this.update();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
protected async addFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): Promise<void> {
|
|
257
|
-
if (commit.fileChangeNodes) {
|
|
258
|
-
this.scmNodes.splice(scmNodesArrayIndex + 1, 0, ...commit.fileChangeNodes.map(node =>
|
|
259
|
-
Object.assign(node, { commitSha: commit.commitDetails.id })
|
|
260
|
-
));
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
protected removeFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): void {
|
|
265
|
-
if (commit.fileChangeNodes) {
|
|
266
|
-
this.scmNodes.splice(scmNodesArrayIndex + 1, commit.fileChangeNodes.length);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
storeState(): object {
|
|
271
|
-
const { options, singleFileMode } = this;
|
|
272
|
-
return {
|
|
273
|
-
options,
|
|
274
|
-
singleFileMode
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
279
|
-
restoreState(oldState: any): void {
|
|
280
|
-
this.options = oldState['options'];
|
|
281
|
-
this.options.maxCount = SCM_HISTORY_MAX_COUNT;
|
|
282
|
-
this.singleFileMode = oldState['singleFileMode'];
|
|
283
|
-
this.setContent(this.options);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
protected onDataReady(): void {
|
|
287
|
-
if (this.status.state === 'ready') {
|
|
288
|
-
this.scmNodes = this.status.commits;
|
|
289
|
-
}
|
|
290
|
-
this.update();
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
protected render(): React.ReactNode {
|
|
294
|
-
let content: React.ReactNode;
|
|
295
|
-
switch (this.status.state) {
|
|
296
|
-
case 'ready':
|
|
297
|
-
content = < React.Fragment >
|
|
298
|
-
{this.renderHistoryHeader()}
|
|
299
|
-
{this.renderCommitList()}
|
|
300
|
-
</React.Fragment>;
|
|
301
|
-
break;
|
|
302
|
-
|
|
303
|
-
case 'error':
|
|
304
|
-
const reason: React.ReactNode = this.status.errorMessage;
|
|
305
|
-
let path: React.ReactNode = '';
|
|
306
|
-
if (this.options.uri) {
|
|
307
|
-
const relPathEncoded = this.scmLabelProvider.relativePath(this.options.uri);
|
|
308
|
-
const relPath = relPathEncoded ? `${decodeURIComponent(relPathEncoded)}` : '';
|
|
309
|
-
|
|
310
|
-
const repo = this.scmService.findRepository(new URI(this.options.uri));
|
|
311
|
-
const repoName = repo ? `${this.labelProvider.getName(new URI(repo.provider.rootUri))}` : '';
|
|
312
|
-
|
|
313
|
-
const relPathAndRepo = [relPath, repoName].filter(Boolean).join(
|
|
314
|
-
` ${nls.localize('theia/git/prepositionIn', 'in')} `
|
|
315
|
-
);
|
|
316
|
-
path = `${relPathAndRepo}`;
|
|
317
|
-
}
|
|
318
|
-
content = <AlertMessage
|
|
319
|
-
type='WARNING'
|
|
320
|
-
header={nls.localize('theia/git/noHistoryForError', 'There is no history available for {0}', `${path}`)}>
|
|
321
|
-
{reason}
|
|
322
|
-
</AlertMessage>;
|
|
323
|
-
break;
|
|
324
|
-
|
|
325
|
-
case 'loading':
|
|
326
|
-
content = <div className='spinnerContainer'>
|
|
327
|
-
<span className={`${codicon('loading')} theia-animation-spin large-spinner`}></span>
|
|
328
|
-
</div>;
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
return <div className='history-container'>
|
|
332
|
-
{content}
|
|
333
|
-
</div>;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
protected renderHistoryHeader(): React.ReactNode {
|
|
337
|
-
if (this.options.uri) {
|
|
338
|
-
const path = this.scmLabelProvider.relativePath(this.options.uri);
|
|
339
|
-
const fileName = path.split('/').pop();
|
|
340
|
-
return <div className='diff-header'>
|
|
341
|
-
{
|
|
342
|
-
this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) })
|
|
343
|
-
}
|
|
344
|
-
{
|
|
345
|
-
this.renderHeaderRow({ name: 'file', value: fileName, title: path })
|
|
346
|
-
}
|
|
347
|
-
<div className='theia-header'>
|
|
348
|
-
Commits
|
|
349
|
-
</div>
|
|
350
|
-
</div>;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
protected renderCommitList(): React.ReactNode {
|
|
355
|
-
const list = <div className='listContainer' id={this.scrollContainer}>
|
|
356
|
-
<ScmHistoryList
|
|
357
|
-
ref={listView => this.listView = (listView || undefined)}
|
|
358
|
-
rows={this.scmNodes}
|
|
359
|
-
hasMoreRows={this.hasMoreCommits}
|
|
360
|
-
loadMoreRows={this.loadMoreRows}
|
|
361
|
-
renderCommit={this.renderCommit}
|
|
362
|
-
renderFileChangeList={this.renderFileChangeList}
|
|
363
|
-
></ScmHistoryList>
|
|
364
|
-
</div>;
|
|
365
|
-
this.allowScrollToSelected = true;
|
|
366
|
-
return list;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
protected readonly loadMoreRows = (index: number) => this.doLoadMoreRows(index);
|
|
370
|
-
protected doLoadMoreRows(index: number): Promise<void> {
|
|
371
|
-
let resolver: () => void;
|
|
372
|
-
const promise = new Promise<void>(resolve => resolver = resolve);
|
|
373
|
-
const lastRow = this.scmNodes[index - 1];
|
|
374
|
-
if (ScmCommitNode.is(lastRow)) {
|
|
375
|
-
const toRevision = lastRow.commitDetails.id;
|
|
376
|
-
this.addCommits({
|
|
377
|
-
range: { toRevision },
|
|
378
|
-
maxCount: SCM_HISTORY_MAX_COUNT,
|
|
379
|
-
uri: this.options.uri
|
|
380
|
-
}).then(() => {
|
|
381
|
-
this.allowScrollToSelected = false;
|
|
382
|
-
this.onDataReady();
|
|
383
|
-
resolver();
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
return promise;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
protected readonly renderCommit = (commit: ScmCommitNode) => this.doRenderCommit(commit);
|
|
390
|
-
protected doRenderCommit(commit: ScmCommitNode): React.ReactNode {
|
|
391
|
-
let expansionToggleIcon = codicon('chevron-right');
|
|
392
|
-
if (commit && commit.expanded) {
|
|
393
|
-
expansionToggleIcon = codicon('chevron-down');
|
|
394
|
-
}
|
|
395
|
-
return <div
|
|
396
|
-
className={`containerHead${commit.selected ? ' ' + SELECTED_CLASS : ''}`}
|
|
397
|
-
onClick={
|
|
398
|
-
e => {
|
|
399
|
-
if (commit.selected && !this.singleFileMode) {
|
|
400
|
-
this.addOrRemoveFileChangeNodes(commit);
|
|
401
|
-
} else {
|
|
402
|
-
this.selectNode(commit);
|
|
403
|
-
}
|
|
404
|
-
e.preventDefault();
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
onDoubleClick={
|
|
408
|
-
e => {
|
|
409
|
-
if (this.singleFileMode && commit.fileChangeNodes.length > 0) {
|
|
410
|
-
this.openFile(commit.fileChangeNodes[0].fileChange);
|
|
411
|
-
}
|
|
412
|
-
e.preventDefault();
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
>
|
|
416
|
-
<div className='headContent'><div className='image-container'>
|
|
417
|
-
<img className='gravatar' src={commit.authorAvatar}></img>
|
|
418
|
-
</div>
|
|
419
|
-
<div className={`headLabelContainer${this.singleFileMode ? ' singleFileMode' : ''}`}>
|
|
420
|
-
<div className='headLabel noWrapInfo noselect'>
|
|
421
|
-
{commit.commitDetails.summary}
|
|
422
|
-
</div>
|
|
423
|
-
<div className='commitTime noWrapInfo noselect'>
|
|
424
|
-
{commit.commitDetails.authorDateRelative + ' by ' + commit.commitDetails.authorName}
|
|
425
|
-
</div>
|
|
426
|
-
</div>
|
|
427
|
-
<div className={`${codicon('eye')} detailButton`} onClick={() => this.openDetailWidget(commit)}></div>
|
|
428
|
-
{!this.singleFileMode && <div className='expansionToggle noselect'>
|
|
429
|
-
<div className='toggle'>
|
|
430
|
-
<div className='number'>{commit.commitDetails.fileChanges.length.toString()}</div>
|
|
431
|
-
<div className={'icon ' + expansionToggleIcon}></div>
|
|
432
|
-
</div>
|
|
433
|
-
</div>}
|
|
434
|
-
</div>
|
|
435
|
-
</div >;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
protected async openDetailWidget(commitNode: ScmCommitNode): Promise<void> {
|
|
439
|
-
const options = {
|
|
440
|
-
...commitNode.commitDetails.commitDetailOptions,
|
|
441
|
-
mode: 'reveal'
|
|
442
|
-
};
|
|
443
|
-
open(
|
|
444
|
-
this.openerService,
|
|
445
|
-
commitNode.commitDetails.commitDetailUri,
|
|
446
|
-
options
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
protected readonly renderFileChangeList = (fileChange: ScmFileChangeNode) => this.doRenderFileChangeList(fileChange);
|
|
451
|
-
protected doRenderFileChangeList(fileChange: ScmFileChangeNode): React.ReactNode {
|
|
452
|
-
const fileChangeElement: React.ReactNode = this.renderScmItem(fileChange, fileChange.commitId);
|
|
453
|
-
return fileChangeElement;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
protected renderScmItem(change: ScmFileChangeNode, commitSha: string): React.ReactNode {
|
|
457
|
-
return <ScmItemComponent key={change.fileChange.uri.toString()} {...{
|
|
458
|
-
labelProvider: this.labelProvider,
|
|
459
|
-
scmLabelProvider: this.scmLabelProvider,
|
|
460
|
-
change,
|
|
461
|
-
revealChange: () => this.openFile(change.fileChange),
|
|
462
|
-
selectNode: () => this.selectNode(change)
|
|
463
|
-
}} />;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
protected override navigateLeft(): void {
|
|
467
|
-
const selected = this.getSelected();
|
|
468
|
-
if (selected && this.status.state === 'ready') {
|
|
469
|
-
if (ScmCommitNode.is(selected)) {
|
|
470
|
-
const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitDetails.id);
|
|
471
|
-
if (selected.expanded) {
|
|
472
|
-
this.addOrRemoveFileChangeNodes(selected);
|
|
473
|
-
} else {
|
|
474
|
-
if (idx > 0) {
|
|
475
|
-
this.selectNode(this.status.commits[idx - 1]);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
} else if (ScmFileChangeNode.is(selected)) {
|
|
479
|
-
const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitId);
|
|
480
|
-
this.selectNode(this.status.commits[idx]);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
this.update();
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
protected override navigateRight(): void {
|
|
487
|
-
const selected = this.getSelected();
|
|
488
|
-
if (selected) {
|
|
489
|
-
if (ScmCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) {
|
|
490
|
-
this.addOrRemoveFileChangeNodes(selected);
|
|
491
|
-
} else {
|
|
492
|
-
this.selectNextNode();
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
this.update();
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
protected override handleListEnter(): void {
|
|
499
|
-
const selected = this.getSelected();
|
|
500
|
-
if (selected) {
|
|
501
|
-
if (ScmCommitNode.is(selected)) {
|
|
502
|
-
if (this.singleFileMode) {
|
|
503
|
-
this.openFile(selected.fileChangeNodes[0].fileChange);
|
|
504
|
-
} else {
|
|
505
|
-
this.openDetailWidget(selected);
|
|
506
|
-
}
|
|
507
|
-
} else if (ScmFileChangeNode.is(selected)) {
|
|
508
|
-
this.openFile(selected.fileChange);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
this.update();
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
protected openFile(change: ScmFileChange): void {
|
|
515
|
-
const uriToOpen = change.getUriToOpen();
|
|
516
|
-
open(this.openerService, uriToOpen, { mode: 'reveal' });
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
export namespace ScmHistoryList {
|
|
521
|
-
export interface Props {
|
|
522
|
-
readonly rows: ScmHistoryListNode[]
|
|
523
|
-
readonly hasMoreRows: boolean
|
|
524
|
-
readonly loadMoreRows: (index: number) => Promise<void>
|
|
525
|
-
readonly renderCommit: (commit: ScmCommitNode) => React.ReactNode
|
|
526
|
-
readonly renderFileChangeList: (fileChange: ScmFileChangeNode) => React.ReactNode
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
export class ScmHistoryList extends React.Component<ScmHistoryList.Props> {
|
|
530
|
-
list: VirtuosoHandle | undefined;
|
|
531
|
-
|
|
532
|
-
protected readonly checkIfRowIsLoaded = (opts: { index: number }) => this.doCheckIfRowIsLoaded(opts);
|
|
533
|
-
protected doCheckIfRowIsLoaded(opts: { index: number }): boolean {
|
|
534
|
-
const row = this.props.rows[opts.index];
|
|
535
|
-
return !!row;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
override render(): React.ReactNode {
|
|
539
|
-
const { hasMoreRows, loadMoreRows, rows } = this.props;
|
|
540
|
-
return <Virtuoso
|
|
541
|
-
ref={list => this.list = (list || undefined)}
|
|
542
|
-
data={rows}
|
|
543
|
-
itemContent={index => this.renderRow(index)}
|
|
544
|
-
endReached={hasMoreRows ? loadMoreRows : undefined}
|
|
545
|
-
overscan={500}
|
|
546
|
-
style={{
|
|
547
|
-
overflowX: 'hidden'
|
|
548
|
-
}}
|
|
549
|
-
/>;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
protected renderRow(index: number): React.ReactNode {
|
|
553
|
-
if (this.checkIfRowIsLoaded({ index })) {
|
|
554
|
-
const row = this.props.rows[index];
|
|
555
|
-
if (ScmCommitNode.is(row)) {
|
|
556
|
-
const head = this.props.renderCommit(row);
|
|
557
|
-
return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
|
|
558
|
-
{head}
|
|
559
|
-
</div>;
|
|
560
|
-
} else if (ScmFileChangeNode.is(row)) {
|
|
561
|
-
return <div className='fileChangeListElement'>
|
|
562
|
-
{this.props.renderFileChangeList(row)}
|
|
563
|
-
</div>;
|
|
564
|
-
}
|
|
565
|
-
} else {
|
|
566
|
-
return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
|
|
567
|
-
<span className={`${codicon('loading')} theia-animation-spin`}></span>
|
|
568
|
-
</div>;
|
|
569
|
-
}
|
|
570
|
-
};
|
|
571
|
-
}
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2018 TypeFox and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
|
|
18
|
+
import { DisposableCollection } from '@theia/core';
|
|
19
|
+
import { OpenerService, open, StatefulWidget, SELECTED_CLASS, WidgetManager, ApplicationShell, codicon } from '@theia/core/lib/browser';
|
|
20
|
+
import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
|
|
21
|
+
import { Message } from '@theia/core/shared/@phosphor/messaging';
|
|
22
|
+
import { Virtuoso, VirtuosoHandle } from '@theia/core/shared/react-virtuoso';
|
|
23
|
+
import URI from '@theia/core/lib/common/uri';
|
|
24
|
+
import { ScmFileChange, ScmFileChangeNode } from '../scm-file-change-node';
|
|
25
|
+
import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service';
|
|
26
|
+
import { ScmItemComponent, ScmNavigableListWidget } from '../scm-navigable-list-widget';
|
|
27
|
+
import * as React from '@theia/core/shared/react';
|
|
28
|
+
import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message';
|
|
29
|
+
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
|
30
|
+
import { nls } from '@theia/core/lib/common/nls';
|
|
31
|
+
import { ScmHistoryProvider } from './scm-history-provider';
|
|
32
|
+
import throttle = require('@theia/core/shared/lodash.throttle');
|
|
33
|
+
import { HistoryWidgetOptions, ScmCommitNode, ScmHistoryListNode, ScmHistorySupport, SCM_HISTORY_ID, SCM_HISTORY_LABEL, SCM_HISTORY_MAX_COUNT } from './scm-history-constants';
|
|
34
|
+
export { HistoryWidgetOptions, ScmCommitNode, ScmHistoryListNode, ScmHistorySupport };
|
|
35
|
+
|
|
36
|
+
@injectable()
|
|
37
|
+
export class ScmHistoryWidget extends ScmNavigableListWidget<ScmHistoryListNode> implements StatefulWidget {
|
|
38
|
+
protected options: HistoryWidgetOptions;
|
|
39
|
+
protected singleFileMode: boolean;
|
|
40
|
+
private cancelIndicator: CancellationTokenSource;
|
|
41
|
+
protected listView: ScmHistoryList | undefined;
|
|
42
|
+
protected hasMoreCommits: boolean;
|
|
43
|
+
protected allowScrollToSelected: boolean;
|
|
44
|
+
|
|
45
|
+
protected status: {
|
|
46
|
+
state: 'loading',
|
|
47
|
+
} | {
|
|
48
|
+
state: 'ready',
|
|
49
|
+
commits: ScmCommitNode[];
|
|
50
|
+
} | {
|
|
51
|
+
state: 'error',
|
|
52
|
+
errorMessage: React.ReactNode
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
protected readonly toDisposeOnRepositoryChange = new DisposableCollection();
|
|
56
|
+
|
|
57
|
+
protected historySupport: ScmHistorySupport | undefined;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
@inject(OpenerService) protected readonly openerService: OpenerService,
|
|
61
|
+
@inject(ApplicationShell) protected readonly shell: ApplicationShell,
|
|
62
|
+
@inject(FileService) protected readonly fileService: FileService,
|
|
63
|
+
@inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService,
|
|
64
|
+
@inject(WidgetManager) protected readonly widgetManager: WidgetManager,
|
|
65
|
+
) {
|
|
66
|
+
super();
|
|
67
|
+
this.id = SCM_HISTORY_ID;
|
|
68
|
+
this.scrollContainer = 'scm-history-list-container';
|
|
69
|
+
this.title.label = SCM_HISTORY_LABEL;
|
|
70
|
+
this.title.caption = SCM_HISTORY_LABEL;
|
|
71
|
+
this.title.iconClass = codicon('history');
|
|
72
|
+
this.title.closable = true;
|
|
73
|
+
this.addClass('theia-scm');
|
|
74
|
+
this.addClass('theia-scm-history');
|
|
75
|
+
this.status = { state: 'loading' };
|
|
76
|
+
this.resetState();
|
|
77
|
+
this.cancelIndicator = new CancellationTokenSource();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@postConstruct()
|
|
81
|
+
protected init(): void {
|
|
82
|
+
this.refreshOnRepositoryChange();
|
|
83
|
+
this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => this.refreshOnRepositoryChange()));
|
|
84
|
+
this.toDispose.push(this.labelProvider.onDidChange(event => {
|
|
85
|
+
if (this.scmNodes.some(node => ScmFileChangeNode.is(node) && event.affects(new URI(node.fileChange.uri)))) {
|
|
86
|
+
this.update();
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected refreshOnRepositoryChange(): void {
|
|
92
|
+
this.toDisposeOnRepositoryChange.dispose();
|
|
93
|
+
|
|
94
|
+
const repository = this.scmService.selectedRepository;
|
|
95
|
+
if (repository && ScmHistoryProvider.is(repository.provider)) {
|
|
96
|
+
this.historySupport = repository.provider.historySupport;
|
|
97
|
+
if (this.historySupport) {
|
|
98
|
+
this.toDisposeOnRepositoryChange.push(this.historySupport.onDidChangeHistory(() => this.setContent(this.options)));
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
this.historySupport = undefined;
|
|
102
|
+
}
|
|
103
|
+
this.setContent(this.options);
|
|
104
|
+
|
|
105
|
+
// If switching repository, discard options because they are specific to a repository
|
|
106
|
+
this.options = this.createHistoryOptions();
|
|
107
|
+
|
|
108
|
+
this.refresh();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected createHistoryOptions(): HistoryWidgetOptions {
|
|
112
|
+
return {
|
|
113
|
+
maxCount: SCM_HISTORY_MAX_COUNT
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
protected readonly toDisposeOnRefresh = new DisposableCollection();
|
|
118
|
+
protected refresh(): void {
|
|
119
|
+
this.toDisposeOnRefresh.dispose();
|
|
120
|
+
this.toDispose.push(this.toDisposeOnRefresh);
|
|
121
|
+
const repository = this.scmService.selectedRepository;
|
|
122
|
+
this.title.label = SCM_HISTORY_LABEL;
|
|
123
|
+
if (repository) {
|
|
124
|
+
this.title.label += ': ' + repository.provider.label;
|
|
125
|
+
}
|
|
126
|
+
const area = this.shell.getAreaFor(this);
|
|
127
|
+
if (area === 'left') {
|
|
128
|
+
this.shell.leftPanelHandler.refresh();
|
|
129
|
+
} else if (area === 'right') {
|
|
130
|
+
this.shell.rightPanelHandler.refresh();
|
|
131
|
+
}
|
|
132
|
+
this.update();
|
|
133
|
+
|
|
134
|
+
if (repository) {
|
|
135
|
+
this.toDisposeOnRefresh.push(repository.onDidChange(() => this.update()));
|
|
136
|
+
// render synchronously to avoid cursor jumping
|
|
137
|
+
// see https://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465
|
|
138
|
+
this.toDisposeOnRefresh.push(repository.input.onDidChange(() => this.setContent(this.options)));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
protected override onAfterAttach(msg: Message): void {
|
|
143
|
+
super.onAfterAttach(msg);
|
|
144
|
+
this.addListNavigationKeyListeners(this.node);
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
+
this.addEventListener<any>(this.node, 'ps-scroll-y', (e: Event & { target: { scrollTop: number } }) => {
|
|
147
|
+
if (this.listView?.list) {
|
|
148
|
+
const { scrollTop } = e.target;
|
|
149
|
+
this.listView.list.scrollTo({
|
|
150
|
+
top: scrollTop
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
setContent = throttle((options?: HistoryWidgetOptions) => this.doSetContent(options), 100);
|
|
157
|
+
|
|
158
|
+
protected async doSetContent(options?: HistoryWidgetOptions): Promise<void> {
|
|
159
|
+
this.resetState(options);
|
|
160
|
+
if (options && options.uri) {
|
|
161
|
+
try {
|
|
162
|
+
const fileStat = await this.fileService.resolve(new URI(options.uri));
|
|
163
|
+
this.singleFileMode = !fileStat.isDirectory;
|
|
164
|
+
} catch {
|
|
165
|
+
this.singleFileMode = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await this.addCommits(options);
|
|
169
|
+
this.onDataReady();
|
|
170
|
+
if (this.scmNodes.length > 0) {
|
|
171
|
+
this.selectNode(this.scmNodes[0]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
protected resetState(options?: HistoryWidgetOptions): void {
|
|
176
|
+
this.options = options || this.createHistoryOptions();
|
|
177
|
+
this.hasMoreCommits = true;
|
|
178
|
+
this.allowScrollToSelected = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
protected async addCommits(options?: HistoryWidgetOptions): Promise<void> {
|
|
182
|
+
const repository = this.scmService.selectedRepository;
|
|
183
|
+
|
|
184
|
+
this.cancelIndicator.cancel();
|
|
185
|
+
this.cancelIndicator = new CancellationTokenSource();
|
|
186
|
+
const token = this.cancelIndicator.token;
|
|
187
|
+
|
|
188
|
+
if (repository) {
|
|
189
|
+
if (this.historySupport) {
|
|
190
|
+
try {
|
|
191
|
+
const history = await this.historySupport.getCommitHistory(options);
|
|
192
|
+
if (token.isCancellationRequested || !this.hasMoreCommits) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (options && (options.maxCount && history.length < options.maxCount)) {
|
|
197
|
+
this.hasMoreCommits = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const avatarCache = new Map<string, string>();
|
|
201
|
+
|
|
202
|
+
const commits: ScmCommitNode[] = [];
|
|
203
|
+
for (const commit of history) {
|
|
204
|
+
const fileChangeNodes: ScmFileChangeNode[] = [];
|
|
205
|
+
await Promise.all(commit.fileChanges.map(async fileChange => {
|
|
206
|
+
fileChangeNodes.push({
|
|
207
|
+
fileChange, commitId: commit.id
|
|
208
|
+
});
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
let avatarUrl = '';
|
|
212
|
+
if (avatarCache.has(commit.authorEmail)) {
|
|
213
|
+
avatarUrl = avatarCache.get(commit.authorEmail)!;
|
|
214
|
+
} else {
|
|
215
|
+
avatarUrl = await this.avatarService.getAvatar(commit.authorEmail);
|
|
216
|
+
avatarCache.set(commit.authorEmail, avatarUrl);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
commits.push({
|
|
220
|
+
commitDetails: commit,
|
|
221
|
+
authorAvatar: avatarUrl,
|
|
222
|
+
fileChangeNodes,
|
|
223
|
+
expanded: false,
|
|
224
|
+
selected: false
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
this.status = { state: 'ready', commits };
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (options && options.uri && repository) {
|
|
230
|
+
this.hasMoreCommits = false;
|
|
231
|
+
}
|
|
232
|
+
this.status = { state: 'error', errorMessage: <React.Fragment> {error.message} </React.Fragment> };
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
this.status = { state: 'error', errorMessage: <React.Fragment>History is not supported for {repository.provider.label} source control.</React.Fragment> };
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
this.status = {
|
|
239
|
+
state: 'error',
|
|
240
|
+
errorMessage: <React.Fragment>{nls.localizeByDefault('No source control providers registered.')}</React.Fragment>
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
protected async addOrRemoveFileChangeNodes(commit: ScmCommitNode): Promise<void> {
|
|
246
|
+
const id = this.scmNodes.findIndex(node => node === commit);
|
|
247
|
+
if (commit.expanded) {
|
|
248
|
+
this.removeFileChangeNodes(commit, id);
|
|
249
|
+
} else {
|
|
250
|
+
await this.addFileChangeNodes(commit, id);
|
|
251
|
+
}
|
|
252
|
+
commit.expanded = !commit.expanded;
|
|
253
|
+
this.update();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
protected async addFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): Promise<void> {
|
|
257
|
+
if (commit.fileChangeNodes) {
|
|
258
|
+
this.scmNodes.splice(scmNodesArrayIndex + 1, 0, ...commit.fileChangeNodes.map(node =>
|
|
259
|
+
Object.assign(node, { commitSha: commit.commitDetails.id })
|
|
260
|
+
));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
protected removeFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): void {
|
|
265
|
+
if (commit.fileChangeNodes) {
|
|
266
|
+
this.scmNodes.splice(scmNodesArrayIndex + 1, commit.fileChangeNodes.length);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
storeState(): object {
|
|
271
|
+
const { options, singleFileMode } = this;
|
|
272
|
+
return {
|
|
273
|
+
options,
|
|
274
|
+
singleFileMode
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
279
|
+
restoreState(oldState: any): void {
|
|
280
|
+
this.options = oldState['options'];
|
|
281
|
+
this.options.maxCount = SCM_HISTORY_MAX_COUNT;
|
|
282
|
+
this.singleFileMode = oldState['singleFileMode'];
|
|
283
|
+
this.setContent(this.options);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
protected onDataReady(): void {
|
|
287
|
+
if (this.status.state === 'ready') {
|
|
288
|
+
this.scmNodes = this.status.commits;
|
|
289
|
+
}
|
|
290
|
+
this.update();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
protected render(): React.ReactNode {
|
|
294
|
+
let content: React.ReactNode;
|
|
295
|
+
switch (this.status.state) {
|
|
296
|
+
case 'ready':
|
|
297
|
+
content = < React.Fragment >
|
|
298
|
+
{this.renderHistoryHeader()}
|
|
299
|
+
{this.renderCommitList()}
|
|
300
|
+
</React.Fragment>;
|
|
301
|
+
break;
|
|
302
|
+
|
|
303
|
+
case 'error':
|
|
304
|
+
const reason: React.ReactNode = this.status.errorMessage;
|
|
305
|
+
let path: React.ReactNode = '';
|
|
306
|
+
if (this.options.uri) {
|
|
307
|
+
const relPathEncoded = this.scmLabelProvider.relativePath(this.options.uri);
|
|
308
|
+
const relPath = relPathEncoded ? `${decodeURIComponent(relPathEncoded)}` : '';
|
|
309
|
+
|
|
310
|
+
const repo = this.scmService.findRepository(new URI(this.options.uri));
|
|
311
|
+
const repoName = repo ? `${this.labelProvider.getName(new URI(repo.provider.rootUri))}` : '';
|
|
312
|
+
|
|
313
|
+
const relPathAndRepo = [relPath, repoName].filter(Boolean).join(
|
|
314
|
+
` ${nls.localize('theia/git/prepositionIn', 'in')} `
|
|
315
|
+
);
|
|
316
|
+
path = `${relPathAndRepo}`;
|
|
317
|
+
}
|
|
318
|
+
content = <AlertMessage
|
|
319
|
+
type='WARNING'
|
|
320
|
+
header={nls.localize('theia/git/noHistoryForError', 'There is no history available for {0}', `${path}`)}>
|
|
321
|
+
{reason}
|
|
322
|
+
</AlertMessage>;
|
|
323
|
+
break;
|
|
324
|
+
|
|
325
|
+
case 'loading':
|
|
326
|
+
content = <div className='spinnerContainer'>
|
|
327
|
+
<span className={`${codicon('loading')} theia-animation-spin large-spinner`}></span>
|
|
328
|
+
</div>;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
return <div className='history-container'>
|
|
332
|
+
{content}
|
|
333
|
+
</div>;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
protected renderHistoryHeader(): React.ReactNode {
|
|
337
|
+
if (this.options.uri) {
|
|
338
|
+
const path = this.scmLabelProvider.relativePath(this.options.uri);
|
|
339
|
+
const fileName = path.split('/').pop();
|
|
340
|
+
return <div className='diff-header'>
|
|
341
|
+
{
|
|
342
|
+
this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) })
|
|
343
|
+
}
|
|
344
|
+
{
|
|
345
|
+
this.renderHeaderRow({ name: 'file', value: fileName, title: path })
|
|
346
|
+
}
|
|
347
|
+
<div className='theia-header'>
|
|
348
|
+
Commits
|
|
349
|
+
</div>
|
|
350
|
+
</div>;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
protected renderCommitList(): React.ReactNode {
|
|
355
|
+
const list = <div className='listContainer' id={this.scrollContainer}>
|
|
356
|
+
<ScmHistoryList
|
|
357
|
+
ref={listView => this.listView = (listView || undefined)}
|
|
358
|
+
rows={this.scmNodes}
|
|
359
|
+
hasMoreRows={this.hasMoreCommits}
|
|
360
|
+
loadMoreRows={this.loadMoreRows}
|
|
361
|
+
renderCommit={this.renderCommit}
|
|
362
|
+
renderFileChangeList={this.renderFileChangeList}
|
|
363
|
+
></ScmHistoryList>
|
|
364
|
+
</div>;
|
|
365
|
+
this.allowScrollToSelected = true;
|
|
366
|
+
return list;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
protected readonly loadMoreRows = (index: number) => this.doLoadMoreRows(index);
|
|
370
|
+
protected doLoadMoreRows(index: number): Promise<void> {
|
|
371
|
+
let resolver: () => void;
|
|
372
|
+
const promise = new Promise<void>(resolve => resolver = resolve);
|
|
373
|
+
const lastRow = this.scmNodes[index - 1];
|
|
374
|
+
if (ScmCommitNode.is(lastRow)) {
|
|
375
|
+
const toRevision = lastRow.commitDetails.id;
|
|
376
|
+
this.addCommits({
|
|
377
|
+
range: { toRevision },
|
|
378
|
+
maxCount: SCM_HISTORY_MAX_COUNT,
|
|
379
|
+
uri: this.options.uri
|
|
380
|
+
}).then(() => {
|
|
381
|
+
this.allowScrollToSelected = false;
|
|
382
|
+
this.onDataReady();
|
|
383
|
+
resolver();
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return promise;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
protected readonly renderCommit = (commit: ScmCommitNode) => this.doRenderCommit(commit);
|
|
390
|
+
protected doRenderCommit(commit: ScmCommitNode): React.ReactNode {
|
|
391
|
+
let expansionToggleIcon = codicon('chevron-right');
|
|
392
|
+
if (commit && commit.expanded) {
|
|
393
|
+
expansionToggleIcon = codicon('chevron-down');
|
|
394
|
+
}
|
|
395
|
+
return <div
|
|
396
|
+
className={`containerHead${commit.selected ? ' ' + SELECTED_CLASS : ''}`}
|
|
397
|
+
onClick={
|
|
398
|
+
e => {
|
|
399
|
+
if (commit.selected && !this.singleFileMode) {
|
|
400
|
+
this.addOrRemoveFileChangeNodes(commit);
|
|
401
|
+
} else {
|
|
402
|
+
this.selectNode(commit);
|
|
403
|
+
}
|
|
404
|
+
e.preventDefault();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
onDoubleClick={
|
|
408
|
+
e => {
|
|
409
|
+
if (this.singleFileMode && commit.fileChangeNodes.length > 0) {
|
|
410
|
+
this.openFile(commit.fileChangeNodes[0].fileChange);
|
|
411
|
+
}
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
>
|
|
416
|
+
<div className='headContent'><div className='image-container'>
|
|
417
|
+
<img className='gravatar' src={commit.authorAvatar}></img>
|
|
418
|
+
</div>
|
|
419
|
+
<div className={`headLabelContainer${this.singleFileMode ? ' singleFileMode' : ''}`}>
|
|
420
|
+
<div className='headLabel noWrapInfo noselect'>
|
|
421
|
+
{commit.commitDetails.summary}
|
|
422
|
+
</div>
|
|
423
|
+
<div className='commitTime noWrapInfo noselect'>
|
|
424
|
+
{commit.commitDetails.authorDateRelative + ' by ' + commit.commitDetails.authorName}
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
<div className={`${codicon('eye')} detailButton`} onClick={() => this.openDetailWidget(commit)}></div>
|
|
428
|
+
{!this.singleFileMode && <div className='expansionToggle noselect'>
|
|
429
|
+
<div className='toggle'>
|
|
430
|
+
<div className='number'>{commit.commitDetails.fileChanges.length.toString()}</div>
|
|
431
|
+
<div className={'icon ' + expansionToggleIcon}></div>
|
|
432
|
+
</div>
|
|
433
|
+
</div>}
|
|
434
|
+
</div>
|
|
435
|
+
</div >;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
protected async openDetailWidget(commitNode: ScmCommitNode): Promise<void> {
|
|
439
|
+
const options = {
|
|
440
|
+
...commitNode.commitDetails.commitDetailOptions,
|
|
441
|
+
mode: 'reveal'
|
|
442
|
+
};
|
|
443
|
+
open(
|
|
444
|
+
this.openerService,
|
|
445
|
+
commitNode.commitDetails.commitDetailUri,
|
|
446
|
+
options
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
protected readonly renderFileChangeList = (fileChange: ScmFileChangeNode) => this.doRenderFileChangeList(fileChange);
|
|
451
|
+
protected doRenderFileChangeList(fileChange: ScmFileChangeNode): React.ReactNode {
|
|
452
|
+
const fileChangeElement: React.ReactNode = this.renderScmItem(fileChange, fileChange.commitId);
|
|
453
|
+
return fileChangeElement;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
protected renderScmItem(change: ScmFileChangeNode, commitSha: string): React.ReactNode {
|
|
457
|
+
return <ScmItemComponent key={change.fileChange.uri.toString()} {...{
|
|
458
|
+
labelProvider: this.labelProvider,
|
|
459
|
+
scmLabelProvider: this.scmLabelProvider,
|
|
460
|
+
change,
|
|
461
|
+
revealChange: () => this.openFile(change.fileChange),
|
|
462
|
+
selectNode: () => this.selectNode(change)
|
|
463
|
+
}} />;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
protected override navigateLeft(): void {
|
|
467
|
+
const selected = this.getSelected();
|
|
468
|
+
if (selected && this.status.state === 'ready') {
|
|
469
|
+
if (ScmCommitNode.is(selected)) {
|
|
470
|
+
const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitDetails.id);
|
|
471
|
+
if (selected.expanded) {
|
|
472
|
+
this.addOrRemoveFileChangeNodes(selected);
|
|
473
|
+
} else {
|
|
474
|
+
if (idx > 0) {
|
|
475
|
+
this.selectNode(this.status.commits[idx - 1]);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} else if (ScmFileChangeNode.is(selected)) {
|
|
479
|
+
const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitId);
|
|
480
|
+
this.selectNode(this.status.commits[idx]);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
this.update();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
protected override navigateRight(): void {
|
|
487
|
+
const selected = this.getSelected();
|
|
488
|
+
if (selected) {
|
|
489
|
+
if (ScmCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) {
|
|
490
|
+
this.addOrRemoveFileChangeNodes(selected);
|
|
491
|
+
} else {
|
|
492
|
+
this.selectNextNode();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
this.update();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
protected override handleListEnter(): void {
|
|
499
|
+
const selected = this.getSelected();
|
|
500
|
+
if (selected) {
|
|
501
|
+
if (ScmCommitNode.is(selected)) {
|
|
502
|
+
if (this.singleFileMode) {
|
|
503
|
+
this.openFile(selected.fileChangeNodes[0].fileChange);
|
|
504
|
+
} else {
|
|
505
|
+
this.openDetailWidget(selected);
|
|
506
|
+
}
|
|
507
|
+
} else if (ScmFileChangeNode.is(selected)) {
|
|
508
|
+
this.openFile(selected.fileChange);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
this.update();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
protected openFile(change: ScmFileChange): void {
|
|
515
|
+
const uriToOpen = change.getUriToOpen();
|
|
516
|
+
open(this.openerService, uriToOpen, { mode: 'reveal' });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export namespace ScmHistoryList {
|
|
521
|
+
export interface Props {
|
|
522
|
+
readonly rows: ScmHistoryListNode[]
|
|
523
|
+
readonly hasMoreRows: boolean
|
|
524
|
+
readonly loadMoreRows: (index: number) => Promise<void>
|
|
525
|
+
readonly renderCommit: (commit: ScmCommitNode) => React.ReactNode
|
|
526
|
+
readonly renderFileChangeList: (fileChange: ScmFileChangeNode) => React.ReactNode
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
export class ScmHistoryList extends React.Component<ScmHistoryList.Props> {
|
|
530
|
+
list: VirtuosoHandle | undefined;
|
|
531
|
+
|
|
532
|
+
protected readonly checkIfRowIsLoaded = (opts: { index: number }) => this.doCheckIfRowIsLoaded(opts);
|
|
533
|
+
protected doCheckIfRowIsLoaded(opts: { index: number }): boolean {
|
|
534
|
+
const row = this.props.rows[opts.index];
|
|
535
|
+
return !!row;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
override render(): React.ReactNode {
|
|
539
|
+
const { hasMoreRows, loadMoreRows, rows } = this.props;
|
|
540
|
+
return <Virtuoso
|
|
541
|
+
ref={list => this.list = (list || undefined)}
|
|
542
|
+
data={rows}
|
|
543
|
+
itemContent={index => this.renderRow(index)}
|
|
544
|
+
endReached={hasMoreRows ? loadMoreRows : undefined}
|
|
545
|
+
overscan={500}
|
|
546
|
+
style={{
|
|
547
|
+
overflowX: 'hidden'
|
|
548
|
+
}}
|
|
549
|
+
/>;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
protected renderRow(index: number): React.ReactNode {
|
|
553
|
+
if (this.checkIfRowIsLoaded({ index })) {
|
|
554
|
+
const row = this.props.rows[index];
|
|
555
|
+
if (ScmCommitNode.is(row)) {
|
|
556
|
+
const head = this.props.renderCommit(row);
|
|
557
|
+
return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
|
|
558
|
+
{head}
|
|
559
|
+
</div>;
|
|
560
|
+
} else if (ScmFileChangeNode.is(row)) {
|
|
561
|
+
return <div className='fileChangeListElement'>
|
|
562
|
+
{this.props.renderFileChangeList(row)}
|
|
563
|
+
</div>;
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
|
|
567
|
+
<span className={`${codicon('loading')} theia-animation-spin`}></span>
|
|
568
|
+
</div>;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|