@theia/scm-extra 1.34.3 → 1.34.4

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.
Files changed (40) hide show
  1. package/LICENSE +641 -641
  2. package/README.md +32 -32
  3. package/lib/browser/history/index.d.ts +3 -3
  4. package/lib/browser/history/index.js +31 -31
  5. package/lib/browser/history/scm-history-constants.d.ts +38 -38
  6. package/lib/browser/history/scm-history-constants.js +41 -41
  7. package/lib/browser/history/scm-history-contribution.d.ts +18 -18
  8. package/lib/browser/history/scm-history-contribution.js +101 -101
  9. package/lib/browser/history/scm-history-frontend-module.d.ts +3 -3
  10. package/lib/browser/history/scm-history-frontend-module.js +33 -33
  11. package/lib/browser/history/scm-history-provider.d.ts +8 -8
  12. package/lib/browser/history/scm-history-provider.js +25 -25
  13. package/lib/browser/history/scm-history-widget.d.ts +89 -89
  14. package/lib/browser/history/scm-history-widget.js +487 -487
  15. package/lib/browser/scm-extra-contribution.d.ts +1 -1
  16. package/lib/browser/scm-extra-contribution.js +20 -20
  17. package/lib/browser/scm-extra-frontend-module.d.ts +3 -3
  18. package/lib/browser/scm-extra-frontend-module.js +26 -26
  19. package/lib/browser/scm-extra-layout-migrations.d.ts +5 -5
  20. package/lib/browser/scm-extra-layout-migrations.js +42 -42
  21. package/lib/browser/scm-file-change-label-provider.d.ts +16 -16
  22. package/lib/browser/scm-file-change-label-provider.js +79 -79
  23. package/lib/browser/scm-file-change-node.d.ts +23 -23
  24. package/lib/browser/scm-file-change-node.js +26 -26
  25. package/lib/browser/scm-navigable-list-widget.d.ts +56 -56
  26. package/lib/browser/scm-navigable-list-widget.js +179 -179
  27. package/package.json +8 -8
  28. package/src/browser/history/index.ts +21 -21
  29. package/src/browser/history/scm-history-constants.ts +69 -69
  30. package/src/browser/history/scm-history-contribution.ts +90 -90
  31. package/src/browser/history/scm-history-frontend-module.ts +36 -36
  32. package/src/browser/history/scm-history-provider.ts +27 -27
  33. package/src/browser/history/scm-history-widget.tsx +566 -566
  34. package/src/browser/scm-extra-contribution.ts +18 -18
  35. package/src/browser/scm-extra-frontend-module.ts +27 -27
  36. package/src/browser/scm-extra-layout-migrations.ts +32 -32
  37. package/src/browser/scm-file-change-label-provider.ts +73 -73
  38. package/src/browser/scm-file-change-node.ts +45 -45
  39. package/src/browser/scm-navigable-list-widget.tsx +197 -197
  40. package/src/browser/style/history.css +127 -127
@@ -1,566 +1,566 @@
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 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: Repository | undefined = this.repositoryProvider.findRepositoryOrSelected(options);
183
- const repository = this.scmService.selectedRepository;
184
-
185
- this.cancelIndicator.cancel();
186
- this.cancelIndicator = new CancellationTokenSource();
187
- const token = this.cancelIndicator.token;
188
-
189
- if (repository) {
190
- if (this.historySupport) {
191
- try {
192
- const currentCommits = this.status.state === 'ready' ? this.status.commits : [];
193
-
194
- let history = await this.historySupport.getCommitHistory(options);
195
- if (token.isCancellationRequested || !this.hasMoreCommits) {
196
- return;
197
- }
198
-
199
- if (options && ((options.maxCount && history.length < options.maxCount) || (!options.maxCount && currentCommits))) {
200
- this.hasMoreCommits = false;
201
- }
202
- if (currentCommits.length > 0) {
203
- history = history.slice(1);
204
- }
205
- const commits: ScmCommitNode[] = [];
206
- for (const commit of history) {
207
- const fileChangeNodes: ScmFileChangeNode[] = [];
208
- await Promise.all(commit.fileChanges.map(async fileChange => {
209
- fileChangeNodes.push({
210
- fileChange, commitId: commit.id
211
- });
212
- }));
213
-
214
- const avatarUrl = await this.avatarService.getAvatar(commit.authorEmail);
215
- commits.push({
216
- commitDetails: commit,
217
- authorAvatar: avatarUrl,
218
- fileChangeNodes,
219
- expanded: false,
220
- selected: false
221
- });
222
- }
223
- currentCommits.push(...commits);
224
- this.status = { state: 'ready', commits: currentCommits };
225
- } catch (error) {
226
- if (options && options.uri && repository) {
227
- this.hasMoreCommits = false;
228
- }
229
- this.status = { state: 'error', errorMessage: <React.Fragment> {error.message} </React.Fragment> };
230
- }
231
- } else {
232
- this.status = { state: 'error', errorMessage: <React.Fragment>History is not supported for {repository.provider.label} source control.</React.Fragment> };
233
- }
234
- } else {
235
- this.status = {
236
- state: 'error',
237
- errorMessage: <React.Fragment>{nls.localizeByDefault('No source control providers registered.')}</React.Fragment>
238
- };
239
- }
240
- }
241
-
242
- protected async addOrRemoveFileChangeNodes(commit: ScmCommitNode): Promise<void> {
243
- const id = this.scmNodes.findIndex(node => node === commit);
244
- if (commit.expanded) {
245
- this.removeFileChangeNodes(commit, id);
246
- } else {
247
- await this.addFileChangeNodes(commit, id);
248
- }
249
- commit.expanded = !commit.expanded;
250
- this.update();
251
- }
252
-
253
- protected async addFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): Promise<void> {
254
- if (commit.fileChangeNodes) {
255
- this.scmNodes.splice(scmNodesArrayIndex + 1, 0, ...commit.fileChangeNodes.map(node =>
256
- Object.assign(node, { commitSha: commit.commitDetails.id })
257
- ));
258
- }
259
- }
260
-
261
- protected removeFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): void {
262
- if (commit.fileChangeNodes) {
263
- this.scmNodes.splice(scmNodesArrayIndex + 1, commit.fileChangeNodes.length);
264
- }
265
- }
266
-
267
- storeState(): object {
268
- const { options, singleFileMode } = this;
269
- return {
270
- options,
271
- singleFileMode
272
- };
273
- }
274
-
275
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
- restoreState(oldState: any): void {
277
- this.options = oldState['options'];
278
- this.options.maxCount = SCM_HISTORY_MAX_COUNT;
279
- this.singleFileMode = oldState['singleFileMode'];
280
- this.setContent(this.options);
281
- }
282
-
283
- protected onDataReady(): void {
284
- if (this.status.state === 'ready') {
285
- this.scmNodes = this.status.commits;
286
- }
287
- this.update();
288
- }
289
-
290
- protected render(): React.ReactNode {
291
- let content: React.ReactNode;
292
- switch (this.status.state) {
293
- case 'ready':
294
- content = < React.Fragment >
295
- {this.renderHistoryHeader()}
296
- {this.renderCommitList()}
297
- </React.Fragment>;
298
- break;
299
-
300
- case 'error':
301
- const reason: React.ReactNode = this.status.errorMessage;
302
- let path: React.ReactNode = '';
303
- if (this.options.uri) {
304
- const relPathEncoded = this.scmLabelProvider.relativePath(this.options.uri);
305
- const relPath = relPathEncoded ? `${decodeURIComponent(relPathEncoded)}` : '';
306
-
307
- const repo = this.scmService.findRepository(new URI(this.options.uri));
308
- const repoName = repo ? `${this.labelProvider.getName(new URI(repo.provider.rootUri))}` : '';
309
-
310
- const relPathAndRepo = [relPath, repoName].filter(Boolean).join(' in ');
311
- path = ` for ${relPathAndRepo}`;
312
- }
313
- content = <AlertMessage
314
- type='WARNING'
315
- header={`There is no history available${path}.`}>
316
- {reason}
317
- </AlertMessage>;
318
- break;
319
-
320
- case 'loading':
321
- content = <div className='spinnerContainer'>
322
- <span className={`${codicon('loading')} theia-animation-spin large-spinner`}></span>
323
- </div>;
324
- break;
325
- }
326
- return <div className='history-container'>
327
- {content}
328
- </div>;
329
- }
330
-
331
- protected renderHistoryHeader(): React.ReactNode {
332
- if (this.options.uri) {
333
- const path = this.scmLabelProvider.relativePath(this.options.uri);
334
- const fileName = path.split('/').pop();
335
- return <div className='diff-header'>
336
- {
337
- this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) })
338
- }
339
- {
340
- this.renderHeaderRow({ name: 'file', value: fileName, title: path })
341
- }
342
- <div className='theia-header'>
343
- Commits
344
- </div>
345
- </div>;
346
- }
347
- }
348
-
349
- protected renderCommitList(): React.ReactNode {
350
- const list = <div className='listContainer' id={this.scrollContainer}>
351
- <ScmHistoryList
352
- ref={listView => this.listView = (listView || undefined)}
353
- rows={this.scmNodes}
354
- hasMoreRows={this.hasMoreCommits}
355
- loadMoreRows={this.loadMoreRows}
356
- renderCommit={this.renderCommit}
357
- renderFileChangeList={this.renderFileChangeList}
358
- ></ScmHistoryList>
359
- </div>;
360
- this.allowScrollToSelected = true;
361
- return list;
362
- }
363
-
364
- protected readonly loadMoreRows = (index: number) => this.doLoadMoreRows(index);
365
- protected doLoadMoreRows(index: number): Promise<void> {
366
- let resolver: () => void;
367
- const promise = new Promise<void>(resolve => resolver = resolve);
368
- const lastRow = this.scmNodes[index - 1];
369
- if (ScmCommitNode.is(lastRow)) {
370
- const toRevision = lastRow.commitDetails.id;
371
- this.addCommits({
372
- range: { toRevision },
373
- maxCount: SCM_HISTORY_MAX_COUNT,
374
- uri: this.options.uri
375
- }).then(() => {
376
- this.allowScrollToSelected = false;
377
- this.onDataReady();
378
- resolver();
379
- });
380
- }
381
- return promise;
382
- }
383
-
384
- protected readonly renderCommit = (commit: ScmCommitNode) => this.doRenderCommit(commit);
385
- protected doRenderCommit(commit: ScmCommitNode): React.ReactNode {
386
- let expansionToggleIcon = codicon('chevron-right');
387
- if (commit && commit.expanded) {
388
- expansionToggleIcon = codicon('chevron-down');
389
- }
390
- return <div
391
- className={`containerHead${commit.selected ? ' ' + SELECTED_CLASS : ''}`}
392
- onClick={
393
- e => {
394
- if (commit.selected && !this.singleFileMode) {
395
- this.addOrRemoveFileChangeNodes(commit);
396
- } else {
397
- this.selectNode(commit);
398
- }
399
- e.preventDefault();
400
- }
401
- }
402
- onDoubleClick={
403
- e => {
404
- if (this.singleFileMode && commit.fileChangeNodes.length > 0) {
405
- this.openFile(commit.fileChangeNodes[0].fileChange);
406
- }
407
- e.preventDefault();
408
- }
409
- }
410
- >
411
- <div className='headContent'><div className='image-container'>
412
- <img className='gravatar' src={commit.authorAvatar}></img>
413
- </div>
414
- <div className={`headLabelContainer${this.singleFileMode ? ' singleFileMode' : ''}`}>
415
- <div className='headLabel noWrapInfo noselect'>
416
- {commit.commitDetails.summary}
417
- </div>
418
- <div className='commitTime noWrapInfo noselect'>
419
- {commit.commitDetails.authorDateRelative + ' by ' + commit.commitDetails.authorName}
420
- </div>
421
- </div>
422
- <div className={`${codicon('eye')} detailButton`} onClick={() => this.openDetailWidget(commit)}></div>
423
- {!this.singleFileMode && <div className='expansionToggle noselect'>
424
- <div className='toggle'>
425
- <div className='number'>{commit.commitDetails.fileChanges.length.toString()}</div>
426
- <div className={'icon ' + expansionToggleIcon}></div>
427
- </div>
428
- </div>}
429
- </div>
430
- </div >;
431
- }
432
-
433
- protected async openDetailWidget(commitNode: ScmCommitNode): Promise<void> {
434
- const options = {
435
- ...commitNode.commitDetails.commitDetailOptions,
436
- mode: 'reveal'
437
- };
438
- open(
439
- this.openerService,
440
- commitNode.commitDetails.commitDetailUri,
441
- options
442
- );
443
- }
444
-
445
- protected readonly renderFileChangeList = (fileChange: ScmFileChangeNode) => this.doRenderFileChangeList(fileChange);
446
- protected doRenderFileChangeList(fileChange: ScmFileChangeNode): React.ReactNode {
447
- const fileChangeElement: React.ReactNode = this.renderScmItem(fileChange, fileChange.commitId);
448
- return fileChangeElement;
449
- }
450
-
451
- protected renderScmItem(change: ScmFileChangeNode, commitSha: string): React.ReactNode {
452
- return <ScmItemComponent key={change.fileChange.uri.toString()} {...{
453
- labelProvider: this.labelProvider,
454
- scmLabelProvider: this.scmLabelProvider,
455
- change,
456
- revealChange: () => this.openFile(change.fileChange),
457
- selectNode: () => this.selectNode(change)
458
- }} />;
459
- }
460
-
461
- protected override navigateLeft(): void {
462
- const selected = this.getSelected();
463
- if (selected && this.status.state === 'ready') {
464
- if (ScmCommitNode.is(selected)) {
465
- const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitDetails.id);
466
- if (selected.expanded) {
467
- this.addOrRemoveFileChangeNodes(selected);
468
- } else {
469
- if (idx > 0) {
470
- this.selectNode(this.status.commits[idx - 1]);
471
- }
472
- }
473
- } else if (ScmFileChangeNode.is(selected)) {
474
- const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitId);
475
- this.selectNode(this.status.commits[idx]);
476
- }
477
- }
478
- this.update();
479
- }
480
-
481
- protected override navigateRight(): void {
482
- const selected = this.getSelected();
483
- if (selected) {
484
- if (ScmCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) {
485
- this.addOrRemoveFileChangeNodes(selected);
486
- } else {
487
- this.selectNextNode();
488
- }
489
- }
490
- this.update();
491
- }
492
-
493
- protected override handleListEnter(): void {
494
- const selected = this.getSelected();
495
- if (selected) {
496
- if (ScmCommitNode.is(selected)) {
497
- if (this.singleFileMode) {
498
- this.openFile(selected.fileChangeNodes[0].fileChange);
499
- } else {
500
- this.openDetailWidget(selected);
501
- }
502
- } else if (ScmFileChangeNode.is(selected)) {
503
- this.openFile(selected.fileChange);
504
- }
505
- }
506
- this.update();
507
- }
508
-
509
- protected openFile(change: ScmFileChange): void {
510
- const uriToOpen = change.getUriToOpen();
511
- open(this.openerService, uriToOpen, { mode: 'reveal' });
512
- }
513
- }
514
-
515
- export namespace ScmHistoryList {
516
- export interface Props {
517
- readonly rows: ScmHistoryListNode[]
518
- readonly hasMoreRows: boolean
519
- readonly loadMoreRows: (index: number) => Promise<void>
520
- readonly renderCommit: (commit: ScmCommitNode) => React.ReactNode
521
- readonly renderFileChangeList: (fileChange: ScmFileChangeNode) => React.ReactNode
522
- }
523
- }
524
- export class ScmHistoryList extends React.Component<ScmHistoryList.Props> {
525
- list: VirtuosoHandle | undefined;
526
-
527
- protected readonly checkIfRowIsLoaded = (opts: { index: number }) => this.doCheckIfRowIsLoaded(opts);
528
- protected doCheckIfRowIsLoaded(opts: { index: number }): boolean {
529
- const row = this.props.rows[opts.index];
530
- return !!row;
531
- }
532
-
533
- override render(): React.ReactNode {
534
- const { hasMoreRows, loadMoreRows, rows } = this.props;
535
- return <Virtuoso
536
- ref={list => this.list = (list || undefined)}
537
- data={rows}
538
- itemContent={index => this.renderRow(index)}
539
- endReached={hasMoreRows ? loadMoreRows : undefined}
540
- overscan={500}
541
- style={{
542
- overflowX: 'hidden'
543
- }}
544
- />;
545
- }
546
-
547
- protected renderRow(index: number): React.ReactNode {
548
- if (this.checkIfRowIsLoaded({ index })) {
549
- const row = this.props.rows[index];
550
- if (ScmCommitNode.is(row)) {
551
- const head = this.props.renderCommit(row);
552
- return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
553
- {head}
554
- </div>;
555
- } else if (ScmFileChangeNode.is(row)) {
556
- return <div className='fileChangeListElement'>
557
- {this.props.renderFileChangeList(row)}
558
- </div>;
559
- }
560
- } else {
561
- return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
562
- <span className={`${codicon('loading')} theia-animation-spin`}></span>
563
- </div>;
564
- }
565
- };
566
- }
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 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: Repository | undefined = this.repositoryProvider.findRepositoryOrSelected(options);
183
+ const repository = this.scmService.selectedRepository;
184
+
185
+ this.cancelIndicator.cancel();
186
+ this.cancelIndicator = new CancellationTokenSource();
187
+ const token = this.cancelIndicator.token;
188
+
189
+ if (repository) {
190
+ if (this.historySupport) {
191
+ try {
192
+ const currentCommits = this.status.state === 'ready' ? this.status.commits : [];
193
+
194
+ let history = await this.historySupport.getCommitHistory(options);
195
+ if (token.isCancellationRequested || !this.hasMoreCommits) {
196
+ return;
197
+ }
198
+
199
+ if (options && ((options.maxCount && history.length < options.maxCount) || (!options.maxCount && currentCommits))) {
200
+ this.hasMoreCommits = false;
201
+ }
202
+ if (currentCommits.length > 0) {
203
+ history = history.slice(1);
204
+ }
205
+ const commits: ScmCommitNode[] = [];
206
+ for (const commit of history) {
207
+ const fileChangeNodes: ScmFileChangeNode[] = [];
208
+ await Promise.all(commit.fileChanges.map(async fileChange => {
209
+ fileChangeNodes.push({
210
+ fileChange, commitId: commit.id
211
+ });
212
+ }));
213
+
214
+ const avatarUrl = await this.avatarService.getAvatar(commit.authorEmail);
215
+ commits.push({
216
+ commitDetails: commit,
217
+ authorAvatar: avatarUrl,
218
+ fileChangeNodes,
219
+ expanded: false,
220
+ selected: false
221
+ });
222
+ }
223
+ currentCommits.push(...commits);
224
+ this.status = { state: 'ready', commits: currentCommits };
225
+ } catch (error) {
226
+ if (options && options.uri && repository) {
227
+ this.hasMoreCommits = false;
228
+ }
229
+ this.status = { state: 'error', errorMessage: <React.Fragment> {error.message} </React.Fragment> };
230
+ }
231
+ } else {
232
+ this.status = { state: 'error', errorMessage: <React.Fragment>History is not supported for {repository.provider.label} source control.</React.Fragment> };
233
+ }
234
+ } else {
235
+ this.status = {
236
+ state: 'error',
237
+ errorMessage: <React.Fragment>{nls.localizeByDefault('No source control providers registered.')}</React.Fragment>
238
+ };
239
+ }
240
+ }
241
+
242
+ protected async addOrRemoveFileChangeNodes(commit: ScmCommitNode): Promise<void> {
243
+ const id = this.scmNodes.findIndex(node => node === commit);
244
+ if (commit.expanded) {
245
+ this.removeFileChangeNodes(commit, id);
246
+ } else {
247
+ await this.addFileChangeNodes(commit, id);
248
+ }
249
+ commit.expanded = !commit.expanded;
250
+ this.update();
251
+ }
252
+
253
+ protected async addFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): Promise<void> {
254
+ if (commit.fileChangeNodes) {
255
+ this.scmNodes.splice(scmNodesArrayIndex + 1, 0, ...commit.fileChangeNodes.map(node =>
256
+ Object.assign(node, { commitSha: commit.commitDetails.id })
257
+ ));
258
+ }
259
+ }
260
+
261
+ protected removeFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): void {
262
+ if (commit.fileChangeNodes) {
263
+ this.scmNodes.splice(scmNodesArrayIndex + 1, commit.fileChangeNodes.length);
264
+ }
265
+ }
266
+
267
+ storeState(): object {
268
+ const { options, singleFileMode } = this;
269
+ return {
270
+ options,
271
+ singleFileMode
272
+ };
273
+ }
274
+
275
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
+ restoreState(oldState: any): void {
277
+ this.options = oldState['options'];
278
+ this.options.maxCount = SCM_HISTORY_MAX_COUNT;
279
+ this.singleFileMode = oldState['singleFileMode'];
280
+ this.setContent(this.options);
281
+ }
282
+
283
+ protected onDataReady(): void {
284
+ if (this.status.state === 'ready') {
285
+ this.scmNodes = this.status.commits;
286
+ }
287
+ this.update();
288
+ }
289
+
290
+ protected render(): React.ReactNode {
291
+ let content: React.ReactNode;
292
+ switch (this.status.state) {
293
+ case 'ready':
294
+ content = < React.Fragment >
295
+ {this.renderHistoryHeader()}
296
+ {this.renderCommitList()}
297
+ </React.Fragment>;
298
+ break;
299
+
300
+ case 'error':
301
+ const reason: React.ReactNode = this.status.errorMessage;
302
+ let path: React.ReactNode = '';
303
+ if (this.options.uri) {
304
+ const relPathEncoded = this.scmLabelProvider.relativePath(this.options.uri);
305
+ const relPath = relPathEncoded ? `${decodeURIComponent(relPathEncoded)}` : '';
306
+
307
+ const repo = this.scmService.findRepository(new URI(this.options.uri));
308
+ const repoName = repo ? `${this.labelProvider.getName(new URI(repo.provider.rootUri))}` : '';
309
+
310
+ const relPathAndRepo = [relPath, repoName].filter(Boolean).join(' in ');
311
+ path = ` for ${relPathAndRepo}`;
312
+ }
313
+ content = <AlertMessage
314
+ type='WARNING'
315
+ header={`There is no history available${path}.`}>
316
+ {reason}
317
+ </AlertMessage>;
318
+ break;
319
+
320
+ case 'loading':
321
+ content = <div className='spinnerContainer'>
322
+ <span className={`${codicon('loading')} theia-animation-spin large-spinner`}></span>
323
+ </div>;
324
+ break;
325
+ }
326
+ return <div className='history-container'>
327
+ {content}
328
+ </div>;
329
+ }
330
+
331
+ protected renderHistoryHeader(): React.ReactNode {
332
+ if (this.options.uri) {
333
+ const path = this.scmLabelProvider.relativePath(this.options.uri);
334
+ const fileName = path.split('/').pop();
335
+ return <div className='diff-header'>
336
+ {
337
+ this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) })
338
+ }
339
+ {
340
+ this.renderHeaderRow({ name: 'file', value: fileName, title: path })
341
+ }
342
+ <div className='theia-header'>
343
+ Commits
344
+ </div>
345
+ </div>;
346
+ }
347
+ }
348
+
349
+ protected renderCommitList(): React.ReactNode {
350
+ const list = <div className='listContainer' id={this.scrollContainer}>
351
+ <ScmHistoryList
352
+ ref={listView => this.listView = (listView || undefined)}
353
+ rows={this.scmNodes}
354
+ hasMoreRows={this.hasMoreCommits}
355
+ loadMoreRows={this.loadMoreRows}
356
+ renderCommit={this.renderCommit}
357
+ renderFileChangeList={this.renderFileChangeList}
358
+ ></ScmHistoryList>
359
+ </div>;
360
+ this.allowScrollToSelected = true;
361
+ return list;
362
+ }
363
+
364
+ protected readonly loadMoreRows = (index: number) => this.doLoadMoreRows(index);
365
+ protected doLoadMoreRows(index: number): Promise<void> {
366
+ let resolver: () => void;
367
+ const promise = new Promise<void>(resolve => resolver = resolve);
368
+ const lastRow = this.scmNodes[index - 1];
369
+ if (ScmCommitNode.is(lastRow)) {
370
+ const toRevision = lastRow.commitDetails.id;
371
+ this.addCommits({
372
+ range: { toRevision },
373
+ maxCount: SCM_HISTORY_MAX_COUNT,
374
+ uri: this.options.uri
375
+ }).then(() => {
376
+ this.allowScrollToSelected = false;
377
+ this.onDataReady();
378
+ resolver();
379
+ });
380
+ }
381
+ return promise;
382
+ }
383
+
384
+ protected readonly renderCommit = (commit: ScmCommitNode) => this.doRenderCommit(commit);
385
+ protected doRenderCommit(commit: ScmCommitNode): React.ReactNode {
386
+ let expansionToggleIcon = codicon('chevron-right');
387
+ if (commit && commit.expanded) {
388
+ expansionToggleIcon = codicon('chevron-down');
389
+ }
390
+ return <div
391
+ className={`containerHead${commit.selected ? ' ' + SELECTED_CLASS : ''}`}
392
+ onClick={
393
+ e => {
394
+ if (commit.selected && !this.singleFileMode) {
395
+ this.addOrRemoveFileChangeNodes(commit);
396
+ } else {
397
+ this.selectNode(commit);
398
+ }
399
+ e.preventDefault();
400
+ }
401
+ }
402
+ onDoubleClick={
403
+ e => {
404
+ if (this.singleFileMode && commit.fileChangeNodes.length > 0) {
405
+ this.openFile(commit.fileChangeNodes[0].fileChange);
406
+ }
407
+ e.preventDefault();
408
+ }
409
+ }
410
+ >
411
+ <div className='headContent'><div className='image-container'>
412
+ <img className='gravatar' src={commit.authorAvatar}></img>
413
+ </div>
414
+ <div className={`headLabelContainer${this.singleFileMode ? ' singleFileMode' : ''}`}>
415
+ <div className='headLabel noWrapInfo noselect'>
416
+ {commit.commitDetails.summary}
417
+ </div>
418
+ <div className='commitTime noWrapInfo noselect'>
419
+ {commit.commitDetails.authorDateRelative + ' by ' + commit.commitDetails.authorName}
420
+ </div>
421
+ </div>
422
+ <div className={`${codicon('eye')} detailButton`} onClick={() => this.openDetailWidget(commit)}></div>
423
+ {!this.singleFileMode && <div className='expansionToggle noselect'>
424
+ <div className='toggle'>
425
+ <div className='number'>{commit.commitDetails.fileChanges.length.toString()}</div>
426
+ <div className={'icon ' + expansionToggleIcon}></div>
427
+ </div>
428
+ </div>}
429
+ </div>
430
+ </div >;
431
+ }
432
+
433
+ protected async openDetailWidget(commitNode: ScmCommitNode): Promise<void> {
434
+ const options = {
435
+ ...commitNode.commitDetails.commitDetailOptions,
436
+ mode: 'reveal'
437
+ };
438
+ open(
439
+ this.openerService,
440
+ commitNode.commitDetails.commitDetailUri,
441
+ options
442
+ );
443
+ }
444
+
445
+ protected readonly renderFileChangeList = (fileChange: ScmFileChangeNode) => this.doRenderFileChangeList(fileChange);
446
+ protected doRenderFileChangeList(fileChange: ScmFileChangeNode): React.ReactNode {
447
+ const fileChangeElement: React.ReactNode = this.renderScmItem(fileChange, fileChange.commitId);
448
+ return fileChangeElement;
449
+ }
450
+
451
+ protected renderScmItem(change: ScmFileChangeNode, commitSha: string): React.ReactNode {
452
+ return <ScmItemComponent key={change.fileChange.uri.toString()} {...{
453
+ labelProvider: this.labelProvider,
454
+ scmLabelProvider: this.scmLabelProvider,
455
+ change,
456
+ revealChange: () => this.openFile(change.fileChange),
457
+ selectNode: () => this.selectNode(change)
458
+ }} />;
459
+ }
460
+
461
+ protected override navigateLeft(): void {
462
+ const selected = this.getSelected();
463
+ if (selected && this.status.state === 'ready') {
464
+ if (ScmCommitNode.is(selected)) {
465
+ const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitDetails.id);
466
+ if (selected.expanded) {
467
+ this.addOrRemoveFileChangeNodes(selected);
468
+ } else {
469
+ if (idx > 0) {
470
+ this.selectNode(this.status.commits[idx - 1]);
471
+ }
472
+ }
473
+ } else if (ScmFileChangeNode.is(selected)) {
474
+ const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitId);
475
+ this.selectNode(this.status.commits[idx]);
476
+ }
477
+ }
478
+ this.update();
479
+ }
480
+
481
+ protected override navigateRight(): void {
482
+ const selected = this.getSelected();
483
+ if (selected) {
484
+ if (ScmCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) {
485
+ this.addOrRemoveFileChangeNodes(selected);
486
+ } else {
487
+ this.selectNextNode();
488
+ }
489
+ }
490
+ this.update();
491
+ }
492
+
493
+ protected override handleListEnter(): void {
494
+ const selected = this.getSelected();
495
+ if (selected) {
496
+ if (ScmCommitNode.is(selected)) {
497
+ if (this.singleFileMode) {
498
+ this.openFile(selected.fileChangeNodes[0].fileChange);
499
+ } else {
500
+ this.openDetailWidget(selected);
501
+ }
502
+ } else if (ScmFileChangeNode.is(selected)) {
503
+ this.openFile(selected.fileChange);
504
+ }
505
+ }
506
+ this.update();
507
+ }
508
+
509
+ protected openFile(change: ScmFileChange): void {
510
+ const uriToOpen = change.getUriToOpen();
511
+ open(this.openerService, uriToOpen, { mode: 'reveal' });
512
+ }
513
+ }
514
+
515
+ export namespace ScmHistoryList {
516
+ export interface Props {
517
+ readonly rows: ScmHistoryListNode[]
518
+ readonly hasMoreRows: boolean
519
+ readonly loadMoreRows: (index: number) => Promise<void>
520
+ readonly renderCommit: (commit: ScmCommitNode) => React.ReactNode
521
+ readonly renderFileChangeList: (fileChange: ScmFileChangeNode) => React.ReactNode
522
+ }
523
+ }
524
+ export class ScmHistoryList extends React.Component<ScmHistoryList.Props> {
525
+ list: VirtuosoHandle | undefined;
526
+
527
+ protected readonly checkIfRowIsLoaded = (opts: { index: number }) => this.doCheckIfRowIsLoaded(opts);
528
+ protected doCheckIfRowIsLoaded(opts: { index: number }): boolean {
529
+ const row = this.props.rows[opts.index];
530
+ return !!row;
531
+ }
532
+
533
+ override render(): React.ReactNode {
534
+ const { hasMoreRows, loadMoreRows, rows } = this.props;
535
+ return <Virtuoso
536
+ ref={list => this.list = (list || undefined)}
537
+ data={rows}
538
+ itemContent={index => this.renderRow(index)}
539
+ endReached={hasMoreRows ? loadMoreRows : undefined}
540
+ overscan={500}
541
+ style={{
542
+ overflowX: 'hidden'
543
+ }}
544
+ />;
545
+ }
546
+
547
+ protected renderRow(index: number): React.ReactNode {
548
+ if (this.checkIfRowIsLoaded({ index })) {
549
+ const row = this.props.rows[index];
550
+ if (ScmCommitNode.is(row)) {
551
+ const head = this.props.renderCommit(row);
552
+ return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
553
+ {head}
554
+ </div>;
555
+ } else if (ScmFileChangeNode.is(row)) {
556
+ return <div className='fileChangeListElement'>
557
+ {this.props.renderFileChangeList(row)}
558
+ </div>;
559
+ }
560
+ } else {
561
+ return <div className={`commitListElement${index === 0 ? ' first' : ''}`} >
562
+ <span className={`${codicon('loading')} theia-animation-spin`}></span>
563
+ </div>;
564
+ }
565
+ };
566
+ }