@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.
@@ -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
+ }