// ***************************************************************************** // Copyright (C) 2018 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { DisposableCollection } from '@theia/core'; import { OpenerService, open, StatefulWidget, SELECTED_CLASS, WidgetManager, ApplicationShell, codicon } from '@theia/core/lib/browser'; import { CancellationTokenSource } from '@theia/core/lib/common/cancellation'; import { Message } from '@theia/core/shared/@lumino/messaging'; import { Virtuoso, VirtuosoHandle } from '@theia/core/shared/react-virtuoso'; import URI from '@theia/core/lib/common/uri'; import { ScmFileChange, ScmFileChangeNode } from '../scm-file-change-node'; import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service'; import { ScmItemComponent, ScmNavigableListWidget } from '../scm-navigable-list-widget'; import * as React from '@theia/core/shared/react'; import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { nls } from '@theia/core/lib/common/nls'; import { ScmHistoryProvider } from './scm-history-provider'; import throttle = require('@theia/core/shared/lodash.throttle'); import { HistoryWidgetOptions, ScmCommitNode, ScmHistoryListNode, ScmHistorySupport, SCM_HISTORY_ID, SCM_HISTORY_LABEL, SCM_HISTORY_MAX_COUNT } from './scm-history-constants'; export { HistoryWidgetOptions, ScmCommitNode, ScmHistoryListNode, ScmHistorySupport }; @injectable() export class ScmHistoryWidget extends ScmNavigableListWidget implements StatefulWidget { protected options: HistoryWidgetOptions; protected singleFileMode: boolean; private cancelIndicator: CancellationTokenSource; protected listView: ScmHistoryList | undefined; protected hasMoreCommits: boolean; protected allowScrollToSelected: boolean; protected status: { state: 'loading', } | { state: 'ready', commits: ScmCommitNode[]; } | { state: 'error', errorMessage: React.ReactNode }; protected readonly toDisposeOnRepositoryChange = new DisposableCollection(); protected historySupport: ScmHistorySupport | undefined; constructor( @inject(OpenerService) protected readonly openerService: OpenerService, @inject(ApplicationShell) protected readonly shell: ApplicationShell, @inject(FileService) protected readonly fileService: FileService, @inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService, @inject(WidgetManager) protected readonly widgetManager: WidgetManager, ) { super(); this.id = SCM_HISTORY_ID; this.scrollContainer = 'scm-history-list-container'; this.title.label = SCM_HISTORY_LABEL; this.title.caption = SCM_HISTORY_LABEL; this.title.iconClass = codicon('history'); this.title.closable = true; this.addClass('theia-scm'); this.addClass('theia-scm-history'); this.status = { state: 'loading' }; this.resetState(); this.cancelIndicator = new CancellationTokenSource(); } @postConstruct() protected init(): void { this.refreshOnRepositoryChange(); this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => this.refreshOnRepositoryChange())); this.toDispose.push(this.labelProvider.onDidChange(event => { if (this.scmNodes.some(node => ScmFileChangeNode.is(node) && event.affects(new URI(node.fileChange.uri)))) { this.update(); } })); } protected refreshOnRepositoryChange(): void { this.toDisposeOnRepositoryChange.dispose(); const repository = this.scmService.selectedRepository; if (repository && ScmHistoryProvider.is(repository.provider)) { this.historySupport = repository.provider.historySupport; if (this.historySupport) { this.toDisposeOnRepositoryChange.push(this.historySupport.onDidChangeHistory(() => this.setContent(this.options))); } } else { this.historySupport = undefined; } this.setContent(this.options); // If switching repository, discard options because they are specific to a repository this.options = this.createHistoryOptions(); this.refresh(); } protected createHistoryOptions(): HistoryWidgetOptions { return { maxCount: SCM_HISTORY_MAX_COUNT }; } protected readonly toDisposeOnRefresh = new DisposableCollection(); protected refresh(): void { this.toDisposeOnRefresh.dispose(); this.toDispose.push(this.toDisposeOnRefresh); const repository = this.scmService.selectedRepository; this.title.label = SCM_HISTORY_LABEL; if (repository) { this.title.label += ': ' + repository.provider.label; } const area = this.shell.getAreaFor(this); if (area === 'left') { this.shell.leftPanelHandler.refresh(); } else if (area === 'right') { this.shell.rightPanelHandler.refresh(); } this.update(); if (repository) { this.toDisposeOnRefresh.push(repository.onDidChange(() => this.update())); // render synchronously to avoid cursor jumping // see https://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465 this.toDisposeOnRefresh.push(repository.input.onDidChange(() => this.setContent(this.options))); } } protected override onAfterAttach(msg: Message): void { super.onAfterAttach(msg); this.addListNavigationKeyListeners(this.node); // eslint-disable-next-line @typescript-eslint/no-explicit-any this.addEventListener(this.node, 'ps-scroll-y', (e: Event & { target: { scrollTop: number } }) => { if (this.listView?.list) { const { scrollTop } = e.target; this.listView.list.scrollTo({ top: scrollTop }); } }); } setContent = throttle((options?: HistoryWidgetOptions) => this.doSetContent(options), 100); protected async doSetContent(options?: HistoryWidgetOptions): Promise { this.resetState(options); if (options && options.uri) { try { const fileStat = await this.fileService.resolve(new URI(options.uri)); this.singleFileMode = !fileStat.isDirectory; } catch { this.singleFileMode = true; } } await this.addCommits(options); this.onDataReady(); if (this.scmNodes.length > 0) { this.selectNode(this.scmNodes[0]); } } protected resetState(options?: HistoryWidgetOptions): void { this.options = options || this.createHistoryOptions(); this.hasMoreCommits = true; this.allowScrollToSelected = true; } protected async addCommits(options?: HistoryWidgetOptions): Promise { const repository = this.scmService.selectedRepository; this.cancelIndicator.cancel(); this.cancelIndicator = new CancellationTokenSource(); const token = this.cancelIndicator.token; if (repository) { if (this.historySupport) { try { const history = await this.historySupport.getCommitHistory(options); if (token.isCancellationRequested || !this.hasMoreCommits) { return; } if (options && (options.maxCount && history.length < options.maxCount)) { this.hasMoreCommits = false; } const avatarCache = new Map(); const commits: ScmCommitNode[] = []; for (const commit of history) { const fileChangeNodes: ScmFileChangeNode[] = []; await Promise.all(commit.fileChanges.map(async fileChange => { fileChangeNodes.push({ fileChange, commitId: commit.id }); })); let avatarUrl = ''; if (avatarCache.has(commit.authorEmail)) { avatarUrl = avatarCache.get(commit.authorEmail)!; } else { avatarUrl = await this.avatarService.getAvatar(commit.authorEmail); avatarCache.set(commit.authorEmail, avatarUrl); } commits.push({ commitDetails: commit, authorAvatar: avatarUrl, fileChangeNodes, expanded: false, selected: false }); } this.status = { state: 'ready', commits }; } catch (error) { if (options && options.uri && repository) { this.hasMoreCommits = false; } this.status = { state: 'error', errorMessage: {error.message} }; } } else { this.status = { state: 'error', errorMessage: History is not supported for {repository.provider.label} source control. }; } } else { this.status = { state: 'error', errorMessage: {nls.localizeByDefault('No source control providers registered.')} }; } } protected async addOrRemoveFileChangeNodes(commit: ScmCommitNode): Promise { const id = this.scmNodes.findIndex(node => node === commit); if (commit.expanded) { this.removeFileChangeNodes(commit, id); } else { await this.addFileChangeNodes(commit, id); } commit.expanded = !commit.expanded; this.update(); } protected async addFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): Promise { if (commit.fileChangeNodes) { this.scmNodes.splice(scmNodesArrayIndex + 1, 0, ...commit.fileChangeNodes.map(node => Object.assign(node, { commitSha: commit.commitDetails.id }) )); } } protected removeFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): void { if (commit.fileChangeNodes) { this.scmNodes.splice(scmNodesArrayIndex + 1, commit.fileChangeNodes.length); } } storeState(): object { const { options, singleFileMode } = this; return { options, singleFileMode }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any restoreState(oldState: any): void { this.options = oldState['options']; this.options.maxCount = SCM_HISTORY_MAX_COUNT; this.singleFileMode = oldState['singleFileMode']; this.setContent(this.options); } protected onDataReady(): void { if (this.status.state === 'ready') { this.scmNodes = this.status.commits; } this.update(); } protected render(): React.ReactNode { let content: React.ReactNode; switch (this.status.state) { case 'ready': content = < React.Fragment > {this.renderHistoryHeader()} {this.renderCommitList()} ; break; case 'error': const reason: React.ReactNode = this.status.errorMessage; let path: React.ReactNode = ''; if (this.options.uri) { const relPathEncoded = this.scmLabelProvider.relativePath(this.options.uri); const relPath = relPathEncoded ? `${decodeURIComponent(relPathEncoded)}` : ''; const repo = this.scmService.findRepository(new URI(this.options.uri)); const repoName = repo ? `${this.labelProvider.getName(new URI(repo.provider.rootUri))}` : ''; const relPathAndRepo = [relPath, repoName].filter(Boolean).join( ` ${nls.localize('theia/scm/prepositionIn', 'in')} ` ); path = `${relPathAndRepo}`; } content = {reason} ; break; case 'loading': content =
; break; } return
{content}
; } protected renderHistoryHeader(): React.ReactNode { if (this.options.uri) { const path = this.scmLabelProvider.relativePath(this.options.uri); const fileName = path.split('/').pop(); return
{ this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) }) } { this.renderHeaderRow({ name: 'file', value: fileName, title: path }) }
Commits
; } } protected renderCommitList(): React.ReactNode { const list =
this.listView = (listView || undefined)} rows={this.scmNodes} hasMoreRows={this.hasMoreCommits} loadMoreRows={this.loadMoreRows} renderCommit={this.renderCommit} renderFileChangeList={this.renderFileChangeList} >
; this.allowScrollToSelected = true; return list; } protected readonly loadMoreRows = (index: number) => this.doLoadMoreRows(index); protected doLoadMoreRows(index: number): Promise { let resolver: () => void; const promise = new Promise(resolve => resolver = resolve); const lastRow = this.scmNodes[index - 1]; if (ScmCommitNode.is(lastRow)) { const toRevision = lastRow.commitDetails.id; this.addCommits({ range: { toRevision }, maxCount: SCM_HISTORY_MAX_COUNT, uri: this.options.uri }).then(() => { this.allowScrollToSelected = false; this.onDataReady(); resolver(); }); } return promise; } protected readonly renderCommit = (commit: ScmCommitNode) => this.doRenderCommit(commit); protected doRenderCommit(commit: ScmCommitNode): React.ReactNode { let expansionToggleIcon = codicon('chevron-right'); if (commit && commit.expanded) { expansionToggleIcon = codicon('chevron-down'); } return
{ if (commit.selected && !this.singleFileMode) { this.addOrRemoveFileChangeNodes(commit); } else { this.selectNode(commit); } e.preventDefault(); } } onDoubleClick={ e => { if (this.singleFileMode && commit.fileChangeNodes.length > 0) { this.openFile(commit.fileChangeNodes[0].fileChange); } e.preventDefault(); } } >
{commit.commitDetails.summary}
{commit.commitDetails.authorDateRelative + ' by ' + commit.commitDetails.authorName}
this.openDetailWidget(commit)}>
{!this.singleFileMode &&
{commit.commitDetails.fileChanges.length.toString()}
}
; } protected async openDetailWidget(commitNode: ScmCommitNode): Promise { const options = { ...commitNode.commitDetails.commitDetailOptions, mode: 'reveal' }; open( this.openerService, commitNode.commitDetails.commitDetailUri, options ); } protected readonly renderFileChangeList = (fileChange: ScmFileChangeNode) => this.doRenderFileChangeList(fileChange); protected doRenderFileChangeList(fileChange: ScmFileChangeNode): React.ReactNode { const fileChangeElement: React.ReactNode = this.renderScmItem(fileChange, fileChange.commitId); return fileChangeElement; } protected renderScmItem(change: ScmFileChangeNode, commitSha: string): React.ReactNode { return this.openFile(change.fileChange), selectNode: () => this.selectNode(change) }} />; } protected override navigateLeft(): void { const selected = this.getSelected(); if (selected && this.status.state === 'ready') { if (ScmCommitNode.is(selected)) { const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitDetails.id); if (selected.expanded) { this.addOrRemoveFileChangeNodes(selected); } else { if (idx > 0) { this.selectNode(this.status.commits[idx - 1]); } } } else if (ScmFileChangeNode.is(selected)) { const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitId); this.selectNode(this.status.commits[idx]); } } this.update(); } protected override navigateRight(): void { const selected = this.getSelected(); if (selected) { if (ScmCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) { this.addOrRemoveFileChangeNodes(selected); } else { this.selectNextNode(); } } this.update(); } protected override handleListEnter(): void { const selected = this.getSelected(); if (selected) { if (ScmCommitNode.is(selected)) { if (this.singleFileMode) { this.openFile(selected.fileChangeNodes[0].fileChange); } else { this.openDetailWidget(selected); } } else if (ScmFileChangeNode.is(selected)) { this.openFile(selected.fileChange); } } this.update(); } protected openFile(change: ScmFileChange): void { const uriToOpen = change.getUriToOpen(); open(this.openerService, uriToOpen, { mode: 'reveal' }); } } export namespace ScmHistoryList { export interface Props { readonly rows: ScmHistoryListNode[] readonly hasMoreRows: boolean readonly loadMoreRows: (index: number) => Promise readonly renderCommit: (commit: ScmCommitNode) => React.ReactNode readonly renderFileChangeList: (fileChange: ScmFileChangeNode) => React.ReactNode } } export class ScmHistoryList extends React.Component { list: VirtuosoHandle | undefined; protected readonly checkIfRowIsLoaded = (opts: { index: number }) => this.doCheckIfRowIsLoaded(opts); protected doCheckIfRowIsLoaded(opts: { index: number }): boolean { const row = this.props.rows[opts.index]; return !!row; } override render(): React.ReactNode { const { hasMoreRows, loadMoreRows, rows } = this.props; return this.list = (list || undefined)} data={rows} itemContent={index => this.renderRow(index)} endReached={hasMoreRows ? loadMoreRows : undefined} overscan={500} style={{ overflowX: 'hidden' }} />; } protected renderRow(index: number): React.ReactNode { if (this.checkIfRowIsLoaded({ index })) { const row = this.props.rows[index]; if (ScmCommitNode.is(row)) { const head = this.props.renderCommit(row); return
{head}
; } else if (ScmFileChangeNode.is(row)) { return
{this.props.renderFileChangeList(row)}
; } } else { return
; } }; }