From 8030c7768352fc05e1d73f4b695cb681c4e9f784 Mon Sep 17 00:00:00 2001 From: Nigel Westbury Date: Sun, 13 Oct 2019 18:48:42 +0100 Subject: [PATCH] Move Git history view to SCM package Signed-off-by: Nigel Westbury --- CHANGELOG.md | 1 + .../src/browser/blame/blame-contribution.ts | 4 +- .../src/browser/diff/git-diff-contribution.ts | 20 +- .../git/src/browser/diff/git-diff-widget.tsx | 51 ++- packages/git/src/browser/git-contribution.ts | 4 +- .../git/src/browser/git-file-change-node.ts | 1 + .../git/src/browser/git-frontend-module.ts | 11 +- .../browser/git-repository-provider.spec.ts | 7 +- .../src/browser/git-repository-provider.ts | 34 +- packages/git/src/browser/git-scm-provider.ts | 147 +++++-- .../history/git-commit-detail-open-handler.ts | 38 +- .../history/git-commit-detail-widget.tsx | 49 ++- .../history/git-history-contribution.ts | 136 ------- .../history/git-history-frontend-module.ts | 23 +- .../browser/history/git-history-support.tsx | 81 ++++ packages/git/src/common/git-model.ts | 7 +- packages/git/src/node/dugite-git.ts | 6 +- .../history/scm-history-contribution.ts | 107 +++++ .../history/scm-history-frontend-module.ts | 34 ++ .../browser/history/scm-history-widget.tsx} | 384 +++++++++++------- packages/scm/src/browser/scm-contribution.ts | 4 +- .../scm/src/browser/scm-frontend-module.ts | 3 + packages/scm/src/browser/scm-input.ts | 7 + .../browser/scm-navigable-list-widget.tsx} | 61 ++- packages/scm/src/browser/scm-provider.ts | 27 +- packages/scm/src/browser/scm-service.ts | 8 + .../src/browser/style/history.css | 3 +- 27 files changed, 773 insertions(+), 485 deletions(-) delete mode 100644 packages/git/src/browser/history/git-history-contribution.ts create mode 100644 packages/git/src/browser/history/git-history-support.tsx create mode 100644 packages/scm/src/browser/history/scm-history-contribution.ts create mode 100644 packages/scm/src/browser/history/scm-history-frontend-module.ts rename packages/{git/src/browser/history/git-history-widget.tsx => scm/src/browser/history/scm-history-widget.tsx} (59%) rename packages/{git/src/browser/git-navigable-list-widget.tsx => scm/src/browser/scm-navigable-list-widget.tsx} (70%) rename packages/{git => scm}/src/browser/style/history.css (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78244edf06212..e628361dde967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Breaking changes: - [task] removed `watchedConfigFileUris`, `watchersMap` `watcherServer`, `fileSystem`, `configFileUris`, `watchConfigurationFile()` and `unwatchConfigurationFile()` from `TaskConfigurations` class. [6268](https://github.com/eclipse-theia/theia/pull/6268) - [task] removed `configurationFileFound` from `TaskService` class. [6268](https://github.com/eclipse-theia/theia/pull/6268) - [core][monaco][task] aligh `ActionProvider` related entities with VS Code. [6302](https://github.com/eclipse-theia/theia/pull/6302) +- [scm][git] the History view (GitHistoryWidget) has moved from the git package to the scm package and renamed to ScmHistoryWidget. GitNavigableListWidget has also moved. [6381](https://github.com/eclipse-theia/theia/pull/6381) ## v0.11.0 diff --git a/packages/git/src/browser/blame/blame-contribution.ts b/packages/git/src/browser/blame/blame-contribution.ts index 07806ca6ac83f..741624d961b48 100644 --- a/packages/git/src/browser/blame/blame-contribution.ts +++ b/packages/git/src/browser/blame/blame-contribution.ts @@ -21,7 +21,7 @@ import { BlameDecorator } from './blame-decorator'; import { EditorManager, EditorKeybindingContexts, EditorWidget, EditorTextFocusContext, StrictEditorTextFocusContext } from '@theia/editor/lib/browser'; import { BlameManager } from './blame-manager'; import URI from '@theia/core/lib/common/uri'; -import { EDITOR_CONTEXT_MENU_GIT } from '../git-contribution'; +import { EDITOR_CONTEXT_MENU_SCM } from '@theia/scm/lib/browser/scm-contribution'; import debounce = require('lodash.debounce'); @@ -136,7 +136,7 @@ export class BlameContribution implements CommandContribution, KeybindingContrib } registerMenus(menus: MenuModelRegistry): void { - menus.registerMenuAction(EDITOR_CONTEXT_MENU_GIT, { + menus.registerMenuAction(EDITOR_CONTEXT_MENU_SCM, { commandId: BlameCommands.TOGGLE_GIT_ANNOTATIONS.id, }); } diff --git a/packages/git/src/browser/diff/git-diff-contribution.ts b/packages/git/src/browser/diff/git-diff-contribution.ts index ad6363a3dd626..082a4368b612a 100644 --- a/packages/git/src/browser/diff/git-diff-contribution.ts +++ b/packages/git/src/browser/diff/git-diff-contribution.ts @@ -19,6 +19,7 @@ import { FrontendApplication, AbstractViewContribution } from '@theia/core/lib/b import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; import { injectable, inject } from 'inversify'; import { GitDiffWidget, GIT_DIFF } from './git-diff-widget'; +import { ScmService } from '@theia/scm/lib/browser/scm-service'; import { open, OpenerService } from '@theia/core/lib/browser'; import { NavigatorContextMenu, FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution'; import { UriCommandHandler } from '@theia/core/lib/common/uri-command-handler'; @@ -27,8 +28,7 @@ import { FileSystem } from '@theia/filesystem/lib/common'; import { DiffUris } from '@theia/core/lib/browser/diff-uris'; import URI from '@theia/core/lib/common/uri'; import { GIT_RESOURCE_SCHEME } from '../git-resource'; -import { Git } from '../../common'; -import { GitRepositoryProvider } from '../git-repository-provider'; +import { Git, Repository } from '../../common'; import { WorkspaceRootUriAwareCommandHandler } from '@theia/workspace/lib/browser/workspace-commands'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; @@ -65,7 +65,7 @@ export class GitDiffContribution extends AbstractViewContribution @inject(FileSystem) protected readonly fileSystem: FileSystem, @inject(OpenerService) protected openerService: OpenerService, @inject(MessageService) protected readonly notifications: MessageService, - @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider + @inject(ScmService) protected readonly scmService: ScmService ) { super({ widgetId: GIT_DIFF, @@ -85,8 +85,8 @@ export class GitDiffContribution extends AbstractViewContribution registerCommands(commands: CommandRegistry): void { commands.registerCommand(GitDiffCommands.OPEN_FILE_DIFF, this.newWorkspaceRootUriAwareCommandHandler({ - isVisible: uri => !!this.repositoryProvider.findRepository(uri), - isEnabled: uri => !!this.repositoryProvider.findRepository(uri), + isVisible: uri => !!this.findGitRepository(uri), + isEnabled: uri => !!this.findGitRepository(uri), execute: async fileUri => { await this.quickOpenService.chooseTagsAndBranches( async (fromRevision, toRevision) => { @@ -112,7 +112,7 @@ export class GitDiffContribution extends AbstractViewContribution } } } - }, this.repositoryProvider.findRepository(fileUri)); + }, this.findGitRepository(fileUri)); } })); } @@ -126,6 +126,14 @@ export class GitDiffContribution extends AbstractViewContribution }); } + protected findGitRepository(uri: URI): Repository | undefined { + const repo = this.scmService.findRepository(uri); + if (repo && repo.provider.id === 'git') { + return { localUri: repo.provider.rootUri }; + } + return undefined; + } + async showWidget(options: Git.Options.Diff): Promise { const widget = await this.widget; await widget.setContent(options); diff --git a/packages/git/src/browser/diff/git-diff-widget.tsx b/packages/git/src/browser/diff/git-diff-widget.tsx index f339c4f691844..a0420d48f38f0 100644 --- a/packages/git/src/browser/diff/git-diff-widget.tsx +++ b/packages/git/src/browser/diff/git-diff-widget.tsx @@ -18,12 +18,16 @@ import { inject, injectable, postConstruct } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { StatefulWidget, SELECTED_CLASS, DiffUris, Message } from '@theia/core/lib/browser'; import { EditorManager, EditorOpenerOptions, EditorWidget, DiffNavigatorProvider, DiffNavigator } from '@theia/editor/lib/browser'; +import { ScmRepository } from '@theia/scm/lib/browser/scm-repository'; +import { ScmService } from '@theia/scm/lib/browser/scm-service'; import { GitFileChange, GitFileStatus, Git, WorkingDirectoryStatus } from '../../common'; +import { GitScmProvider, GitScmFileChange } from '../git-scm-provider'; import { GitWatcher } from '../../common'; import { GIT_RESOURCE_SCHEME } from '../git-resource'; -import { GitNavigableListWidget } from '../git-navigable-list-widget'; +import { ScmNavigableListWidget } from '@theia/scm/lib/browser/scm-navigable-list-widget'; import { GitFileChangeNode } from '../git-file-change-node'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { GitRepositoryProvider } from '../git-repository-provider'; import * as React from 'react'; import { MaybePromise } from '@theia/core/lib/common/types'; @@ -31,7 +35,7 @@ import { MaybePromise } from '@theia/core/lib/common/types'; export const GIT_DIFF = 'git-diff'; @injectable() -export class GitDiffWidget extends GitNavigableListWidget implements StatefulWidget { +export class GitDiffWidget extends ScmNavigableListWidget implements StatefulWidget { protected readonly GIT_DIFF_TITLE = 'Diff'; @@ -45,9 +49,11 @@ export class GitDiffWidget extends GitNavigableListWidget imp protected deferredListContainer = new Deferred(); @inject(Git) protected readonly git: Git; + @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider; @inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(GitWatcher) protected readonly gitWatcher: GitWatcher; + @inject(ScmService) protected readonly sucmService: ScmService; constructor() { super(); @@ -84,8 +90,10 @@ export class GitDiffWidget extends GitNavigableListWidget imp async setContent(options: Git.Options.Diff): Promise { this.options = options; - const repository = this.repositoryProvider.findRepositoryOrSelected(options); - if (repository) { + const scmRepository = this.findRepositoryOrSelected(options.uri); + if (scmRepository && scmRepository.provider.id === 'git') { + const provider = scmRepository.provider as GitScmProvider; + const repository = { localUri: scmRepository.provider.rootUri }; const fileChanges: GitFileChange[] = await this.git.diff(repository, { range: options.range, uri: options.uri @@ -99,9 +107,11 @@ export class GitDiffWidget extends GitNavigableListWidget imp this.relativePath(fileChangeUri.parent) ]); - const caption = this.computeCaption(fileChange); + const gitScmFileChange = new GitScmFileChange(fileChange, provider, options.range); + const caption = this.computeCaption(gitScmFileChange); + const statusCaption = gitScmFileChange.getStatusCaption(); fileChangeNodes.push({ - ...fileChange, icon, label, description, caption + ...fileChange, icon, label, description, caption, statusCaption }); } this.fileChangeNodes = fileChangeNodes; @@ -109,6 +119,13 @@ export class GitDiffWidget extends GitNavigableListWidget imp } } + protected findRepositoryOrSelected(uri?: string): ScmRepository | undefined { + if (uri) { + return this.scmService.findRepository(new URI(uri)); + } + return this.scmService.selectedRepository; + } + storeState(): object { const { fileChangeNodes, options } = this; return { @@ -132,7 +149,7 @@ export class GitDiffWidget extends GitNavigableListWidget imp } protected render(): React.ReactNode { - this.gitNodes = this.fileChangeNodes; + this.scmNodes = this.fileChangeNodes; const commitishBar = this.renderDiffListHeader(); const fileChangeList = this.renderFileChangeList(); return
{commitishBar}{fileChangeList}
; @@ -249,7 +266,7 @@ export class GitDiffWidget extends GitNavigableListWidget imp protected doAddGitDiffListKeyListeners(id: string): void { const container = document.getElementById(id); if (container) { - this.addGitListNavigationKeyListeners(container); + this.addListNavigationKeyListeners(container); } } @@ -277,7 +294,7 @@ export class GitDiffWidget extends GitNavigableListWidget imp
- {this.getStatusCaption(change.status, true).charAt(0)} + {change.statusCaption ? change.statusCaption.charAt(0) : undefined}
; } @@ -299,8 +316,8 @@ export class GitDiffWidget extends GitNavigableListWidget imp this.revealChange(selected); } }); - } else if (this.gitNodes.length > 0) { - this.selectNode(this.gitNodes[0]); + } else if (this.scmNodes.length > 0) { + this.selectNode(this.scmNodes[0]); this.openSelected(); } } @@ -327,19 +344,19 @@ export class GitDiffWidget extends GitNavigableListWidget imp protected selectNextNode(): void { const idx = this.indexOfSelected; - if (idx >= 0 && idx < this.gitNodes.length - 1) { - this.selectNode(this.gitNodes[idx + 1]); - } else if (this.gitNodes.length > 0 && (idx === -1 || idx === this.gitNodes.length - 1)) { - this.selectNode(this.gitNodes[0]); + if (idx >= 0 && idx < this.scmNodes.length - 1) { + this.selectNode(this.scmNodes[idx + 1]); + } else if (this.scmNodes.length > 0 && (idx === -1 || idx === this.scmNodes.length - 1)) { + this.selectNode(this.scmNodes[0]); } } protected selectPreviousNode(): void { const idx = this.indexOfSelected; if (idx > 0) { - this.selectNode(this.gitNodes[idx - 1]); + this.selectNode(this.scmNodes[idx - 1]); } else if (idx === 0) { - this.selectNode(this.gitNodes[this.gitNodes.length - 1]); + this.selectNode(this.scmNodes[this.scmNodes.length - 1]); } } diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts index 8e809ad80f0c6..f6fd1558f5cf6 100644 --- a/packages/git/src/browser/git-contribution.ts +++ b/packages/git/src/browser/git-contribution.ts @@ -18,7 +18,7 @@ import URI from '@theia/core/lib/common/uri'; import { Command, CommandContribution, CommandRegistry, DisposableCollection, MenuContribution, MenuModelRegistry, Mutable, MenuAction } from '@theia/core'; import { DiffUris, Widget } from '@theia/core/lib/browser'; import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; -import { EDITOR_CONTEXT_MENU, EditorContextMenu, EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser'; +import { EditorContextMenu, EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser'; import { Git, GitFileChange, GitFileStatus } from '../common'; import { GitRepositoryTracker } from './git-repository-tracker'; import { GitAction, GitQuickOpenService } from './git-quick-open-service'; @@ -30,8 +30,6 @@ import { ScmWidget } from '@theia/scm/lib/browser/scm-widget'; import { ScmResource, ScmCommand } from '@theia/scm/lib/browser/scm-provider'; import { ProgressService } from '@theia/core/lib/common/progress-service'; -export const EDITOR_CONTEXT_MENU_GIT = [...EDITOR_CONTEXT_MENU, '3_git']; - export namespace GIT_COMMANDS { export const CLONE = { id: 'git.clone', diff --git a/packages/git/src/browser/git-file-change-node.ts b/packages/git/src/browser/git-file-change-node.ts index d1adeb7e8ac6b..c8c5f51a4f3b4 100644 --- a/packages/git/src/browser/git-file-change-node.ts +++ b/packages/git/src/browser/git-file-change-node.ts @@ -21,6 +21,7 @@ export interface GitFileChangeNode extends GitFileChange { readonly label: string; readonly description: string; readonly caption?: string; + readonly statusCaption?: string; readonly extraIconClassName?: string; readonly commitSha?: string; selected?: boolean; diff --git a/packages/git/src/browser/git-frontend-module.ts b/packages/git/src/browser/git-frontend-module.ts index a71b0f71c5a80..08be0be0c224c 100644 --- a/packages/git/src/browser/git-frontend-module.ts +++ b/packages/git/src/browser/git-frontend-module.ts @@ -42,6 +42,8 @@ import { GitCommitMessageValidator } from './git-commit-message-validator'; import { GitSyncService } from './git-sync-service'; import { GitErrorHandler } from './git-error-handler'; import { GitScmProvider } from './git-scm-provider'; +import { GitHistorySupport } from './history/git-history-support'; +import { ScmHistorySupport } from '@theia/scm/lib/browser/history/scm-history-widget'; export default new ContainerModule(bind => { bindGitPreferences(bind); @@ -64,9 +66,16 @@ export default new ContainerModule(bind => { bind(GitResourceResolver).toSelf().inSingletonScope(); bind(ResourceResolver).toService(GitResourceResolver); - bind(GitScmProvider.Factory).toFactory(GitScmProvider.createFactory); + bind(GitScmProvider.ContainerFactory).toFactory(GitScmProvider.createFactory); bind(GitRepositoryProvider).toSelf().inSingletonScope(); bind(GitQuickOpenService).toSelf().inSingletonScope(); + bind(GitScmProvider.ScmTypeContainer).toDynamicValue(({ container }) => { + const child = container.createChild(); + child.bind(GitScmProvider).toSelf().inTransientScope(); + child.bind(GitHistorySupport).toSelf().inTransientScope(); + child.bind(ScmHistorySupport).toService(GitHistorySupport); + return child; + }).inSingletonScope(); bind(LabelProviderContribution).to(GitUriLabelProviderContribution).inSingletonScope(); bind(NavigatorTreeDecorator).to(GitDecorator).inSingletonScope(); diff --git a/packages/git/src/browser/git-repository-provider.spec.ts b/packages/git/src/browser/git-repository-provider.spec.ts index 046fe02de6bcf..ae2d686eb286a 100644 --- a/packages/git/src/browser/git-repository-provider.spec.ts +++ b/packages/git/src/browser/git-repository-provider.spec.ts @@ -98,7 +98,12 @@ describe('GitRepositoryProvider', () => { testContainer.bind(FileSystemWatcher).toConstantValue(mockFileSystemWatcher); testContainer.bind(StorageService).toConstantValue(mockStorageService); testContainer.bind(ScmService).toSelf().inSingletonScope(); - testContainer.bind(GitScmProvider.Factory).toFactory(GitScmProvider.createFactory); + testContainer.bind(GitScmProvider.ContainerFactory).toFactory(GitScmProvider.createFactory); + testContainer.bind(GitScmProvider.ScmTypeContainer).toDynamicValue(({ container }) => { + const child = container.createChild(); + child.bind(GitScmProvider).toSelf().inTransientScope(); + return child; + }).inSingletonScope(); testContainer.bind(ScmContextKeyService).toSelf().inSingletonScope(); testContainer.bind(ContextKeyService).toSelf().inSingletonScope(); testContainer.bind(GitCommitMessageValidator).toSelf().inSingletonScope(); diff --git a/packages/git/src/browser/git-repository-provider.ts b/packages/git/src/browser/git-repository-provider.ts index 6bb5563d633ab..10ff13cb7c6d4 100644 --- a/packages/git/src/browser/git-repository-provider.ts +++ b/packages/git/src/browser/git-repository-provider.ts @@ -21,7 +21,6 @@ import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service import { FileSystem } from '@theia/filesystem/lib/common'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { StorageService } from '@theia/core/lib/browser/storage-service'; -import URI from '@theia/core/lib/common/uri'; import { FileSystemWatcher } from '@theia/filesystem/lib/browser/filesystem-watcher'; import { Git, Repository } from '../common'; import { GitCommitMessageValidator } from './git-commit-message-validator'; @@ -40,8 +39,8 @@ export class GitRepositoryProvider { protected readonly selectedRepoStorageKey = 'theia-git-selected-repository'; protected readonly allRepoStorageKey = 'theia-git-all-repositories'; - @inject(GitScmProvider.Factory) - protected readonly scmProviderFactory: GitScmProvider.Factory; + @inject(GitScmProvider.ContainerFactory) + protected readonly scmProviderFactory: GitScmProvider.ContainerFactory; @inject(GitCommitMessageValidator) protected readonly commitMessageValidator: GitCommitMessageValidator; @@ -123,29 +122,6 @@ export class GitRepositoryProvider { return repositories; } - findRepository(uri: URI): Repository | undefined { - const reposSorted = this.allRepositories.sort(Repository.sortComparator); - return reposSorted.find(repo => new URI(repo.localUri).isEqualOrParent(uri)); - } - - findRepositoryOrSelected(arg: URI | string | { uri?: string | URI } | undefined): Repository | undefined { - let uri: URI | string | undefined; - if (arg) { - if (arg instanceof URI || typeof arg === 'string') { - uri = arg; - } else if (typeof arg === 'object' && 'uri' in arg && arg.uri) { - uri = arg.uri; - } - if (uri) { - if (typeof uri === 'string') { - uri = new URI(uri); - } - return this.findRepository(uri); - } - } - return this.selectedRepository; - } - async refresh(options?: GitRefreshOptions): Promise { const repositories: Repository[] = []; const refreshing: Promise[] = []; @@ -186,7 +162,8 @@ export class GitRepositoryProvider { } protected registerScmProvider(repository: Repository): void { - const provider = this.scmProviderFactory({ repository }); + const providerContainer = this.scmProviderFactory({ repository }); + const provider = providerContainer.get(GitScmProvider); this.scmService.registerScmProvider(provider, { input: { placeholder: 'Message (press {0} to commit)', @@ -196,7 +173,8 @@ export class GitRepositoryProvider { message: issue.message, type: issue.status }; - } + }, + providerContainer } }); } diff --git a/packages/git/src/browser/git-scm-provider.ts b/packages/git/src/browser/git-scm-provider.ts index b4c94d94d2353..1fe5aa1bb7658 100644 --- a/packages/git/src/browser/git-scm-provider.ts +++ b/packages/git/src/browser/git-scm-provider.ts @@ -30,7 +30,8 @@ import { Repository, Git, CommitWithChanges, GitFileChange, WorkingDirectoryStat import { GIT_RESOURCE_SCHEME } from './git-resource'; import { GitErrorHandler } from './git-error-handler'; import { EditorWidget } from '@theia/editor/lib/browser'; -import { ScmProvider, ScmCommand, ScmResourceGroup, ScmAmendSupport, ScmCommit } from '@theia/scm/lib/browser/scm-provider'; +import { ScmProvider, ScmCommand, ScmResourceGroup, ScmAmendSupport, ScmCommit, ScmFileChange } from '@theia/scm/lib/browser/scm-provider'; +import { GitCommitDetailWidgetOptions } from './history/git-commit-detail-widget'; @injectable() export class GitScmProviderOptions { @@ -84,7 +85,7 @@ export class GitScmProvider implements ScmProvider { @postConstruct() protected init(): void { - this._amendSupport = new GitAmendSupport(this.repository, this.git); + this._amendSupport = new GitAmendSupport(this, this.repository, this.git); } get repository(): Repository { @@ -362,8 +363,59 @@ export class GitScmProvider implements ScmProvider { await Promise.all(uris.map(uri => this.delete(new URI(uri)))); } + public createScmCommit(gitCommit: CommitWithChanges): ScmCommit { + const range = { + fromRevision: gitCommit.sha + '~1', + toRevision: gitCommit.sha + }; + + const scmCommit: GitScmCommit = { + id: gitCommit.sha, + commitDetailUri: this.toCommitDetailUri(gitCommit.sha), + summary: gitCommit.summary, + messageBody: gitCommit.body, + authorName: gitCommit.author.name, + authorEmail: gitCommit.author.email, + authorTimestamp: gitCommit.author.timestamp, + authorDateRelative: gitCommit.authorDateRelative, + scmProvider: this, + gitFileChanges: gitCommit.fileChanges.map(change => new GitScmFileChange(change, this, range)), + get fileChanges(): ScmFileChange[] { + return this.gitFileChanges; + }, + get commitDetailOptions(): GitCommitDetailWidgetOptions { + return { + commitSha: gitCommit.sha, + commitMessage: gitCommit.summary, + messageBody: gitCommit.body, + authorName: gitCommit.author.name, + authorEmail: gitCommit.author.email, + authorDate: gitCommit.author.timestamp, + authorDateRelative: gitCommit.authorDateRelative, + }; + } + }; + return scmCommit; + } + + public relativePath(uri: string): string { + const parsedUri = new URI(uri); + const gitRepo = { localUri: this.rootUri }; + const relativePath = Repository.relativePath(gitRepo, parsedUri); + if (relativePath) { + return relativePath.toString(); + } + return this.labelProvider.getLongName(parsedUri); + } + + protected toCommitDetailUri(commitSha: string): URI { + return new URI('').withScheme(GitScmProvider.GIT_COMMIT_DETAIL).withFragment(commitSha); + } + } export namespace GitScmProvider { + export const GIT_COMMIT_DETAIL = 'git-commit-detail-widget'; + export interface State { status?: WorkingDirectoryStatus stagedChanges: GitFileChange[] @@ -380,21 +432,22 @@ export namespace GitScmProvider { groups: [] }; } - export const Factory = Symbol('GitScmProvider.Factory'); - export type Factory = (options: GitScmProviderOptions) => GitScmProvider; - export function createFactory(ctx: interfaces.Context): Factory { + export type ContainerFactory = (options: GitScmProviderOptions) => interfaces.Container; + export function createFactory(ctx: interfaces.Context): ContainerFactory { + const typeContainer = ctx.container.get(GitScmProvider.ScmTypeContainer as interfaces.ServiceIdentifier); return (options: GitScmProviderOptions) => { - const container = ctx.container.createChild(); + const container = typeContainer.createChild(); container.bind(GitScmProviderOptions).toConstantValue(options); - container.bind(GitScmProvider).toSelf().inSingletonScope(); - return container.get(GitScmProvider); + return container; }; } + export const ScmTypeContainer = Symbol('GitScmProvider.TypeContainer'); + export const ContainerFactory = Symbol('GitScmProvider.ProviderContainer'); } export class GitAmendSupport implements ScmAmendSupport { - constructor(protected readonly repository: Repository, protected readonly git: Git) { } + constructor(protected readonly provider: GitScmProvider, protected readonly repository: Repository, protected readonly git: Git) { } public async getInitialAmendingCommits(amendingHeadCommitSha: string, latestCommitSha: string): Promise { const commits = await this.git.log( @@ -406,7 +459,7 @@ export class GitAmendSupport implements ScmAmendSupport { } ); - return commits.map(this.createScmCommit); + return commits.map(commit => this.provider.createScmCommit(commit)); } public async getMessage(commit: string): Promise { @@ -420,23 +473,67 @@ export class GitAmendSupport implements ScmAmendSupport { public async getLastCommit(): Promise { const commits = await this.git.log(this.repository, { maxCount: 1 }); if (commits.length > 0) { - return this.createScmCommit(commits[0]); + return this.provider.createScmCommit(commits[0]); } } +} - private createScmCommit(gitCommit: CommitWithChanges): { - id: string; - summary: string; - authorName: string; - authorEmail: string; - authorDateRelative: string; - } { - return { - id: gitCommit.sha, - summary: gitCommit.summary, - authorName: gitCommit.author.name, - authorEmail: gitCommit.author.email, - authorDateRelative: gitCommit.authorDateRelative - }; +export interface GitScmCommit extends ScmCommit { + scmProvider: GitScmProvider; + gitFileChanges: GitScmFileChange[]; +} + +export class GitScmFileChange implements ScmFileChange { + + constructor( + protected readonly fileChange: GitFileChange, + protected readonly scmProvider: GitScmProvider, + protected readonly range?: Git.Options.Range + ) { } + + get uri(): string { + return this.fileChange.uri; + } + + getCaption(): string { + const provider = this.scmProvider; + let result = `${provider.relativePath(this.fileChange.uri)} - ${GitFileStatus.toString(this.fileChange.status, true)}`; + if (this.fileChange.oldUri) { + result = `${provider.relativePath(this.fileChange.oldUri)} -> ${result}`; + } + return result; + } + + getStatusCaption(): string { + return GitFileStatus.toString(this.fileChange.status, this.fileChange.staged); + } + + getStatusAbbreviation(): string { + return GitFileStatus.toAbbreviation(this.fileChange.status, this.fileChange.staged); + } + + getClassNameForStatus(): string { + return GitFileStatus[this.fileChange.status]; + } + + getUriToOpen(): URI { + const uri: URI = new URI(this.fileChange.uri); + const fromFileURI = this.fileChange.oldUri ? new URI(this.fileChange.oldUri) : uri; // set oldUri on renamed and copied + if (!this.range) { + return uri; + } + const fromRevision = this.range.fromRevision || 'HEAD'; + const toRevision = this.range.toRevision || 'HEAD'; + const fromURI = fromFileURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(fromRevision.toString()); + const toURI = uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(toRevision.toString()); + let uriToOpen = uri; + if (this.fileChange.status === GitFileStatus.Deleted) { + uriToOpen = fromURI; + } else if (this.fileChange.status === GitFileStatus.New) { + uriToOpen = toURI; + } else { + uriToOpen = DiffUris.encode(fromURI, toURI); + } + return uriToOpen; } } diff --git a/packages/git/src/browser/history/git-commit-detail-open-handler.ts b/packages/git/src/browser/history/git-commit-detail-open-handler.ts index 079c930167619..1e584f42fda84 100644 --- a/packages/git/src/browser/history/git-commit-detail-open-handler.ts +++ b/packages/git/src/browser/history/git-commit-detail-open-handler.ts @@ -17,13 +17,11 @@ import { injectable } from 'inversify'; import { WidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; -import { GIT_COMMIT_DETAIL, GitCommitDetailWidgetOptions, GitCommitDetailWidget, GitCommitDetails } from './git-commit-detail-widget'; +import { GitCommitDetailWidgetOptions, GitCommitDetailWidget } from './git-commit-detail-widget'; +import { GitScmProvider } from '../git-scm-provider'; export namespace GitCommitDetailUri { - export const scheme = GIT_COMMIT_DETAIL; - export function toUri(commitSha: string): URI { - return new URI('').withScheme(scheme).withFragment(commitSha); - } + export const scheme = GitScmProvider.GIT_COMMIT_DETAIL; export function toCommitSha(uri: URI): string { if (uri.scheme === scheme) { return uri.fragment; @@ -36,7 +34,7 @@ export type GitCommitDetailOpenerOptions = WidgetOpenerOptions & GitCommitDetail @injectable() export class GitCommitDetailOpenHandler extends WidgetOpenHandler { - readonly id = GIT_COMMIT_DETAIL; + readonly id = GitScmProvider.GIT_COMMIT_DETAIL; canHandle(uri: URI): number { try { @@ -48,31 +46,17 @@ export class GitCommitDetailOpenHandler extends WidgetOpenHandler { - widget.setContent({ range: options.range }); + widget.setContent({ + range: { + fromRevision: options.commitSha + '~1', + toRevision: options.commitSha + } + }); await super.doOpen(widget, options); } protected createWidgetOptions(uri: URI, commit: GitCommitDetailOpenerOptions): GitCommitDetailWidgetOptions { - return this.getCommitDetailWidgetOptions(commit); - } - - getCommitDetailWidgetOptions(commit: GitCommitDetails): GitCommitDetailWidgetOptions { - const range = { - fromRevision: commit.commitSha + '~1', - toRevision: commit.commitSha - }; - return { - range, - authorAvatar: commit.authorAvatar, - authorDate: commit.authorDate, - authorDateRelative: commit.authorDateRelative, - authorEmail: commit.authorEmail, - authorName: commit.authorName, - commitMessage: commit.commitMessage, - fileChangeNodes: commit.fileChangeNodes, - messageBody: commit.messageBody, - commitSha: commit.commitSha - }; + return commit; } } diff --git a/packages/git/src/browser/history/git-commit-detail-widget.tsx b/packages/git/src/browser/history/git-commit-detail-widget.tsx index 8e7e7b2d6fb91..445e79274761f 100644 --- a/packages/git/src/browser/history/git-commit-detail-widget.tsx +++ b/packages/git/src/browser/history/git-commit-detail-widget.tsx @@ -14,50 +14,55 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject } from 'inversify'; +import { injectable, inject, postConstruct } from 'inversify'; import { Widget } from '@phosphor/widgets'; import { LabelProvider } from '@theia/core/lib/browser'; -import { Git, GitFileChange } from '../../common'; +import { GitFileChange } from '../../common'; import { GitDiffWidget } from '../diff/git-diff-widget'; import { GitRepositoryProvider } from '../git-repository-provider'; -import { GitFileChangeNode } from '../git-file-change-node'; +import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service'; import * as React from 'react'; -export const GIT_COMMIT_DETAIL = 'git-commit-detail-widget'; - -export interface GitCommitDetails { - readonly authorName: string; - readonly authorEmail: string; - readonly authorDate: Date; - readonly authorDateRelative: string; - readonly authorAvatar: string; - readonly commitMessage: string; - readonly messageBody?: string; - readonly fileChangeNodes: GitFileChangeNode[]; - readonly commitSha: string; -} - export const GitCommitDetailWidgetOptions = Symbol('GitCommitDetailWidgetOptions'); -export interface GitCommitDetailWidgetOptions extends GitCommitDetails { - readonly range: Git.Options.Range +export interface GitCommitDetailWidgetOptions { + commitSha: string; + commitMessage: string; + messageBody?: string; + authorName: string; + authorEmail: string; + authorDate: string; + authorDateRelative: string; } @injectable() export class GitCommitDetailWidget extends GitDiffWidget { + protected authorAvatar: string; + constructor( @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider, @inject(LabelProvider) protected readonly labelProvider: LabelProvider, + @inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService, @inject(GitCommitDetailWidgetOptions) protected readonly commitDetailOptions: GitCommitDetailWidgetOptions ) { super(); this.id = 'commit' + commitDetailOptions.commitSha; - this.title.label = commitDetailOptions.commitSha; - this.options = { range: commitDetailOptions.range }; + this.title.label = commitDetailOptions.commitSha.substr(0, 8); + this.options = { + range: { + fromRevision: commitDetailOptions.commitSha + '~1', + toRevision: commitDetailOptions.commitSha + } + }; this.title.closable = true; this.title.iconClass = 'icon-git-commit tab-git-icon'; } + @postConstruct() + protected async init(): Promise { + this.authorAvatar = await this.avatarService.getAvatar(this.commitDetailOptions.authorEmail); + } + protected renderDiffListHeader(): React.ReactNode { const authorEMail = this.commitDetailOptions.authorEmail; const subject =
{this.commitDetailOptions.commitMessage}
; @@ -83,7 +88,7 @@ export class GitCommitDetailWidget extends GitDiffWidget {
{this.commitDetailOptions.commitSha}
; const gravatar =
-
; + ; const commitInfo =
{gravatar}
{authorRow}{mailRow}{dateRow}{revisionRow}
; const header =
Files changed
; diff --git a/packages/git/src/browser/history/git-history-contribution.ts b/packages/git/src/browser/history/git-history-contribution.ts deleted file mode 100644 index 4a23bfa852f17..0000000000000 --- a/packages/git/src/browser/history/git-history-contribution.ts +++ /dev/null @@ -1,136 +0,0 @@ -/******************************************************************************** - * 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 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { MenuModelRegistry, CommandRegistry, Command, SelectionService } from '@theia/core'; -import { AbstractViewContribution, OpenViewArguments } from '@theia/core/lib/browser'; -import { injectable, inject, postConstruct } from 'inversify'; -import { NavigatorContextMenu } from '@theia/navigator/lib/browser/navigator-contribution'; -import { UriCommandHandler, UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler'; -import URI from '@theia/core/lib/common/uri'; -import { GitHistoryWidget } from './git-history-widget'; -import { Git } from '../../common'; -import { GitRepositoryTracker } from '../git-repository-tracker'; -import { GitRepositoryProvider } from '../git-repository-provider'; -import { EDITOR_CONTEXT_MENU_GIT } from '../git-contribution'; - -export const GIT_HISTORY_ID = 'git-history'; -export const GIT_HISTORY_LABEL = 'Git History'; -export const GIT_HISTORY_TOGGLE_KEYBINDING = 'alt+h'; -export const GIT_HISTORY_MAX_COUNT = 100; - -export namespace GitHistoryCommands { - export const OPEN_FILE_HISTORY: Command = { - id: 'git-history:open-file-history', - }; - export const OPEN_BRANCH_HISTORY: Command = { - id: 'git-history:open-branch-history', - label: GIT_HISTORY_LABEL - }; -} - -export interface GitHistoryOpenViewArguments extends OpenViewArguments { - uri: string | undefined; -} - -@injectable() -export class GitHistoryContribution extends AbstractViewContribution { - - @inject(SelectionService) - protected readonly selectionService: SelectionService; - @inject(GitRepositoryTracker) - protected readonly repositoryTracker: GitRepositoryTracker; - @inject(GitRepositoryProvider) - protected readonly repositoryProvider: GitRepositoryProvider; - - constructor() { - super({ - widgetId: GIT_HISTORY_ID, - widgetName: GIT_HISTORY_LABEL, - defaultWidgetOptions: { - area: 'left', - rank: 500 - }, - toggleCommandId: GitHistoryCommands.OPEN_BRANCH_HISTORY.id, - toggleKeybinding: GIT_HISTORY_TOGGLE_KEYBINDING - }); - } - - @postConstruct() - protected init(): void { - this.repositoryTracker.onDidChangeRepository(async repository => { - this.refreshWidget(repository ? repository.localUri : undefined); - } - ); - this.repositoryTracker.onGitEvent(event => { - const { source, status, oldStatus } = event || { source: undefined, status: undefined, oldStatus: undefined }; - let isBranchChanged = false; - let isHeaderChanged = false; - if (oldStatus) { - isBranchChanged = !!status && status.branch !== oldStatus.branch; - isHeaderChanged = !!status && status.currentHead !== oldStatus.currentHead; - } - if (isBranchChanged || isHeaderChanged || oldStatus === undefined) { - this.refreshWidget(source && source.localUri); - } - }); - } - - async openView(args?: Partial): Promise { - const widget = await super.openView(args); - this.refreshWidget(args!.uri); - return widget; - } - - registerMenus(menus: MenuModelRegistry): void { - menus.registerMenuAction(NavigatorContextMenu.SEARCH, { - commandId: GitHistoryCommands.OPEN_FILE_HISTORY.id, - label: GIT_HISTORY_LABEL - }); - menus.registerMenuAction(EDITOR_CONTEXT_MENU_GIT, { - commandId: GitHistoryCommands.OPEN_FILE_HISTORY.id, - label: GIT_HISTORY_LABEL - }); - super.registerMenus(menus); - } - - registerCommands(commands: CommandRegistry): void { - commands.registerCommand(GitHistoryCommands.OPEN_FILE_HISTORY, this.newUriAwareCommandHandler({ - isEnabled: (uri: URI) => !!this.repositoryProvider.findRepository(uri), - isVisible: (uri: URI) => !!this.repositoryProvider.findRepository(uri), - execute: async uri => this.openView({ activate: true, uri: uri.toString() }), - })); - super.registerCommands(commands); - } - - protected async refreshWidget(uri: string | undefined): Promise { - const widget = this.tryGetWidget(); - if (!widget) { - // the widget doesn't exist, so don't wake it up - return; - } - const options: Git.Options.Log = { - uri, - maxCount: GIT_HISTORY_MAX_COUNT, - shortSha: true - }; - await widget.setContent(options); - } - - protected newUriAwareCommandHandler(handler: UriCommandHandler): UriAwareCommandHandler { - return new UriAwareCommandHandler(this.selectionService, handler); - } - -} diff --git a/packages/git/src/browser/history/git-history-frontend-module.ts b/packages/git/src/browser/history/git-history-frontend-module.ts index a9ae42b4a4814..d24fc39037bb3 100644 --- a/packages/git/src/browser/history/git-history-frontend-module.ts +++ b/packages/git/src/browser/history/git-history-frontend-module.ts @@ -14,27 +14,20 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { GitHistoryContribution, GIT_HISTORY_ID } from './git-history-contribution'; import { interfaces, Container } from 'inversify'; -import { WidgetFactory, OpenHandler, bindViewContribution } from '@theia/core/lib/browser'; -import { GitHistoryWidget } from './git-history-widget'; -import { GIT_COMMIT_DETAIL, GitCommitDetailWidget, GitCommitDetails, GitCommitDetailWidgetOptions } from './git-commit-detail-widget'; +import { WidgetFactory, OpenHandler } from '@theia/core/lib/browser'; +import { GitCommitDetailWidget, GitCommitDetailWidgetOptions } from './git-commit-detail-widget'; +import { GitCommitDetailOpenHandler } from './git-commit-detail-open-handler'; +import { GitScmProvider } from '../git-scm-provider'; +import { ScmCommitNode } from '@theia/scm/lib/browser/history/scm-history-widget'; -import '../../../src/browser/style/history.css'; import '../../../src/browser/style/git-icons.css'; -import { GitCommitDetailOpenHandler } from './git-commit-detail-open-handler'; export function bindGitHistoryModule(bind: interfaces.Bind): void { - bind(GitHistoryWidget).toSelf(); - bind(WidgetFactory).toDynamicValue(ctx => ({ - id: GIT_HISTORY_ID, - createWidget: () => ctx.container.get(GitHistoryWidget) - })); - bind(WidgetFactory).toDynamicValue(ctx => ({ - id: GIT_COMMIT_DETAIL, - createWidget: (options: GitCommitDetails) => { + id: GitScmProvider.GIT_COMMIT_DETAIL, + createWidget: (options: ScmCommitNode) => { const child = new Container({ defaultScope: 'Singleton' }); child.parent = ctx.container; child.bind(GitCommitDetailWidget).toSelf(); @@ -46,6 +39,4 @@ export function bindGitHistoryModule(bind: interfaces.Bind): void { bind(GitCommitDetailOpenHandler).toSelf(); bind(OpenHandler).toService(GitCommitDetailOpenHandler); - bindViewContribution(bind, GitHistoryContribution); - } diff --git a/packages/git/src/browser/history/git-history-support.tsx b/packages/git/src/browser/history/git-history-support.tsx new file mode 100644 index 0000000000000..86bb3d0042907 --- /dev/null +++ b/packages/git/src/browser/history/git-history-support.tsx @@ -0,0 +1,81 @@ +/******************************************************************************** + * Copyright (C) 2019 Arm 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable } from 'inversify'; +import { Emitter, Disposable } from '@theia/core'; +import { Git } from '../../common'; +import { ScmHistorySupport, HistoryWidgetOptions } from '@theia/scm/lib/browser/history/scm-history-widget'; +import { ScmCommit } from '@theia/scm/lib/browser/scm-provider'; +import { GitScmProvider } from '../git-scm-provider'; +import { GitRepositoryTracker } from '../git-repository-tracker'; + +@injectable() +export class GitHistorySupport implements ScmHistorySupport { + + @inject(GitScmProvider) protected readonly provider: GitScmProvider; + @inject(Git) protected readonly git: Git; + @inject(GitRepositoryTracker) protected readonly repositoryTracker: GitRepositoryTracker; + + async getCommitHistory(options?: HistoryWidgetOptions): Promise { + const repository = this.provider.repository; + const gitOptions: Git.Options.Log = { + uri: options ? options.uri : undefined, + maxCount: options ? options.maxCount : undefined, + shortSha: true + }; + + const commits = await this.git.log(repository, gitOptions); + if (commits.length > 0) { + return commits.map(commit => this.provider.createScmCommit(commit)); + } else { + const pathIsUnderVersionControl = !options || !options.uri || await this.git.lsFiles(repository, options.uri, { errorUnmatch: true }); + if (!pathIsUnderVersionControl) { + throw new Error('It is not under version control.'); + } else { + throw new Error('No commits have been committed.'); + } + } + } + + protected readonly onDidChangeHistoryEmitter = new Emitter({ + onFirstListenerAdd: () => this.onFirstListenerAdd(), + onLastListenerRemove: () => this.onLastListenerRemove() + }); + readonly onDidChangeHistory = this.onDidChangeHistoryEmitter.event; + + protected onGitEventDisposable: Disposable | undefined; + protected onFirstListenerAdd(): void { + this.onGitEventDisposable = this.repositoryTracker.onGitEvent(event => { + const { status, oldStatus } = event || { status: undefined, oldStatus: undefined }; + let isBranchChanged = false; + let isHeaderChanged = false; + if (oldStatus) { + isBranchChanged = !!status && status.branch !== oldStatus.branch; + isHeaderChanged = !!status && status.currentHead !== oldStatus.currentHead; + } + if (isBranchChanged || isHeaderChanged || oldStatus === undefined) { + this.onDidChangeHistoryEmitter.fire(undefined); + } + }); + } + + protected onLastListenerRemove(): void { + if (this.onGitEventDisposable) { + this.onGitEventDisposable.dispose(); + this.onGitEventDisposable = undefined; + } + } +} diff --git a/packages/git/src/common/git-model.ts b/packages/git/src/common/git-model.ts index 60bc971940ab1..35579d5c3b10b 100644 --- a/packages/git/src/common/git-model.ts +++ b/packages/git/src/common/git-model.ts @@ -153,7 +153,7 @@ export interface GitFileChange { readonly oldUri?: string; /** - * `true` if the file is staged, otherwise `false`. If absent, it means, not staged. + * `true` if the file is staged or committed, `false` if not staged. If absent, it means not staged. */ readonly staged?: boolean; @@ -195,11 +195,10 @@ export namespace Repository { export function is(repository: Object | undefined): repository is Repository { return !!repository && 'localUri' in repository; } - export function relativePath(repository: Repository | URI | string, uri: URI | string): Path | undefined { + export function relativePath(repository: Repository | URI, uri: URI | string): Path | undefined { const repositoryUri = new URI(Repository.is(repository) ? repository.localUri : String(repository)); return repositoryUri.relative(new URI(String(uri))); } - export const sortComparator = (ra: Repository, rb: Repository) => rb.localUri.length - ra.localUri.length; } /** @@ -339,7 +338,7 @@ export interface CommitWithChanges extends Commit { readonly authorDateRelative: string; /** - * The number of file changes per commit. + * The file changes in the commit. */ readonly fileChanges: GitFileChange[]; } diff --git a/packages/git/src/node/dugite-git.ts b/packages/git/src/node/dugite-git.ts index f1ff64aab230d..9c06c9593c06f 100644 --- a/packages/git/src/node/dugite-git.ts +++ b/packages/git/src/node/dugite-git.ts @@ -90,14 +90,16 @@ export class NameStatusParser extends OutputParser { changes.push({ status, uri, - oldUri + oldUri, + staged: true }); index = index + 3; } else { const uri = this.toUri(repositoryUri, items[index + 1]); changes.push({ status, - uri + uri, + staged: true }); index = index + 2; } diff --git a/packages/scm/src/browser/history/scm-history-contribution.ts b/packages/scm/src/browser/history/scm-history-contribution.ts new file mode 100644 index 0000000000000..e78ac4f5db207 --- /dev/null +++ b/packages/scm/src/browser/history/scm-history-contribution.ts @@ -0,0 +1,107 @@ +/******************************************************************************** + * 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { MenuModelRegistry, CommandRegistry, Command, SelectionService } from '@theia/core'; +import { AbstractViewContribution, OpenViewArguments } from '@theia/core/lib/browser'; +import { injectable, inject } from 'inversify'; +import { NavigatorContextMenu } from '@theia/navigator/lib/browser/navigator-contribution'; +import { UriCommandHandler, UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler'; +import URI from '@theia/core/lib/common/uri'; +import { ScmHistoryWidget } from './scm-history-widget'; +import { ScmService } from '../scm-service'; +import { EDITOR_CONTEXT_MENU_SCM } from '../scm-contribution'; + +export const SCM_HISTORY_ID = 'scm-history'; +export const SCM_HISTORY_LABEL = 'Source History'; +export const SCM_HISTORY_TOGGLE_KEYBINDING = 'alt+h'; +export const SCM_HISTORY_MAX_COUNT = 100; + +export namespace ScmHistoryCommands { + export const OPEN_FILE_HISTORY: Command = { + id: 'scm-history:open-file-history', + }; + export const OPEN_BRANCH_HISTORY: Command = { + id: 'scm-history:open-branch-history', + label: SCM_HISTORY_LABEL + }; +} + +export interface ScmHistoryOpenViewArguments extends OpenViewArguments { + uri: string | undefined; +} + +@injectable() +export class ScmHistoryContribution extends AbstractViewContribution { + + @inject(SelectionService) + protected readonly selectionService: SelectionService; + @inject(ScmService) + protected readonly scmService: ScmService; + + constructor() { + super({ + widgetId: SCM_HISTORY_ID, + widgetName: SCM_HISTORY_LABEL, + defaultWidgetOptions: { + area: 'left', + rank: 500 + }, + toggleCommandId: ScmHistoryCommands.OPEN_BRANCH_HISTORY.id, + toggleKeybinding: SCM_HISTORY_TOGGLE_KEYBINDING + }); + } + + async openView(args?: Partial): Promise { + const widget = await super.openView(args); + this.refreshWidget(args!.uri); + return widget; + } + + registerMenus(menus: MenuModelRegistry): void { + menus.registerMenuAction(NavigatorContextMenu.SEARCH, { + commandId: ScmHistoryCommands.OPEN_FILE_HISTORY.id, + label: SCM_HISTORY_LABEL + }); + menus.registerMenuAction(EDITOR_CONTEXT_MENU_SCM, { + commandId: ScmHistoryCommands.OPEN_FILE_HISTORY.id, + label: SCM_HISTORY_LABEL + }); + super.registerMenus(menus); + } + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(ScmHistoryCommands.OPEN_FILE_HISTORY, this.newUriAwareCommandHandler({ + isEnabled: (uri: URI) => !!this.scmService.findRepository(uri), + isVisible: (uri: URI) => !!this.scmService.findRepository(uri), + execute: async uri => this.openView({ activate: true, uri: uri.toString() }), + })); + super.registerCommands(commands); + } + + protected async refreshWidget(uri: string | undefined): Promise { + const widget = this.tryGetWidget(); + if (!widget) { + // the widget doesn't exist, so don't wake it up + return; + } + await widget.setContent({ uri }); + } + + protected newUriAwareCommandHandler(handler: UriCommandHandler): UriAwareCommandHandler { + return new UriAwareCommandHandler(this.selectionService, handler); + } + +} diff --git a/packages/scm/src/browser/history/scm-history-frontend-module.ts b/packages/scm/src/browser/history/scm-history-frontend-module.ts new file mode 100644 index 0000000000000..27990073ecb52 --- /dev/null +++ b/packages/scm/src/browser/history/scm-history-frontend-module.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (C) 2019 Arm 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { interfaces } from 'inversify'; +import { ScmHistoryContribution, SCM_HISTORY_ID } from './scm-history-contribution'; +import { WidgetFactory, bindViewContribution } from '@theia/core/lib/browser'; +import { ScmHistoryWidget } from './scm-history-widget'; + +import '../../../src/browser/style/history.css'; + +export function bindScmHistoryModule(bind: interfaces.Bind): void { + + bind(ScmHistoryWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(ctx => ({ + id: SCM_HISTORY_ID, + createWidget: () => ctx.container.get(ScmHistoryWidget) + })); + + bindViewContribution(bind, ScmHistoryContribution); + +} diff --git a/packages/git/src/browser/history/git-history-widget.tsx b/packages/scm/src/browser/history/scm-history-widget.tsx similarity index 59% rename from packages/git/src/browser/history/git-history-widget.tsx rename to packages/scm/src/browser/history/scm-history-widget.tsx index 37fe84c55bcb4..8739fe01b0358 100644 --- a/packages/git/src/browser/history/git-history-widget.tsx +++ b/packages/scm/src/browser/history/scm-history-widget.tsx @@ -14,47 +14,77 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject } from 'inversify'; -import { DiffUris } from '@theia/core/lib/browser/diff-uris'; +import { injectable, inject, postConstruct } from 'inversify'; +import { Event as TheiaEvent, DisposableCollection } from '@theia/core'; import { OpenerService, open, StatefulWidget, SELECTED_CLASS, WidgetManager, ApplicationShell } from '@theia/core/lib/browser'; import { CancellationTokenSource } from '@theia/core/lib/common/cancellation'; import { Message } from '@phosphor/messaging'; import { AutoSizer, List, ListRowRenderer, ListRowProps, InfiniteLoader, IndexRange, ScrollParams, CellMeasurerCache, CellMeasurer } from 'react-virtualized'; -import { GIT_RESOURCE_SCHEME } from '../git-resource'; import URI from '@theia/core/lib/common/uri'; -import { GIT_HISTORY_ID, GIT_HISTORY_MAX_COUNT, GIT_HISTORY_LABEL } from './git-history-contribution'; -import { GitFileStatus, Git, GitFileChange, Repository } from '../../common'; +import { ScmService } from '../scm-service'; +import { SCM_HISTORY_ID, SCM_HISTORY_MAX_COUNT, SCM_HISTORY_LABEL } from './scm-history-contribution'; +import { ScmCommit, ScmFileChange } from '../scm-provider'; import { FileSystem } from '@theia/filesystem/lib/common'; -import { GitDiffContribution } from '../diff/git-diff-contribution'; -import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service'; -import { GitCommitDetailUri, GitCommitDetailOpenerOptions, GitCommitDetailOpenHandler } from './git-commit-detail-open-handler'; -import { GitCommitDetails } from './git-commit-detail-widget'; -import { GitNavigableListWidget } from '../git-navigable-list-widget'; -import { GitFileChangeNode } from '../git-file-change-node'; +import { ScmAvatarService } from '../scm-avatar-service'; +import { ScmNavigableListWidget } from '../scm-navigable-list-widget'; import * as React from 'react'; import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message'; -export interface GitCommitNode extends GitCommitDetails { - fileChanges?: GitFileChange[]; +export const ScmHistorySupport = Symbol('scm-history-support'); +export interface ScmHistorySupport { + getCommitHistory(options?: HistoryWidgetOptions): Promise; + readonly onDidChangeHistory: TheiaEvent; +} + +export interface ScmCommitNode { + commitDetails: ScmCommit; + authorAvatar: string; + fileChangeNodes?: ScmFileChangeNode[]; expanded: boolean; selected: boolean; } +export interface ScmFileChangeNode { + readonly fileChange: ScmFileChange; + readonly icon: string; + readonly label: string; + readonly description: string; + readonly caption?: string; + readonly extraIconClassName?: string; + readonly commitSha?: string; + selected?: boolean; +} + +export interface HistoryWidgetOptions { + readonly range?: { + readonly toRevision?: string; + readonly fromRevision?: string; + }; + readonly uri?: string; + readonly maxCount?: number; +} + +export namespace ScmCommitNode { + // tslint:disable-next-line:no-any + export function is(node: any): node is ScmCommitNode { + return !!node && 'commitDetails' in node && 'expanded' in node && 'selected' in node; + } +} -export namespace GitCommitNode { +export namespace ScmFileChangeNode { // tslint:disable-next-line:no-any - export function is(node: any): node is GitCommitNode { - return !!node && 'commitSha' in node && 'commitMessage' in node && 'fileChangeNodes' in node; + export function is(node: any): node is ScmFileChangeNode { + return !!node && 'fileChange' in node && 'icon' in node && 'label' in node && 'description' in node; } } -export type GitHistoryListNode = (GitCommitNode | GitFileChangeNode); +export type ScmHistoryListNode = (ScmCommitNode | ScmFileChangeNode); @injectable() -export class GitHistoryWidget extends GitNavigableListWidget implements StatefulWidget { - protected options: Git.Options.Log; +export class ScmHistoryWidget extends ScmNavigableListWidget implements StatefulWidget { + protected options: HistoryWidgetOptions; protected singleFileMode: boolean; private cancelIndicator: CancellationTokenSource; - protected listView: GitHistoryList | undefined; + protected listView: ScmHistoryList | undefined; protected hasMoreCommits: boolean; protected allowScrollToSelected: boolean; @@ -62,36 +92,90 @@ export class GitHistoryWidget extends GitNavigableListWidget state: 'loading', } | { state: 'ready', - commits: GitCommitNode[]; + commits: ScmCommitNode[]; } | { state: 'error', errorMessage: React.ReactNode }; + protected readonly toDisposeOnRepositoryChange = new DisposableCollection(); + + protected historySupport: ScmHistorySupport | undefined; + constructor( + @inject(ScmService) protected readonly scmService: ScmService, @inject(OpenerService) protected readonly openerService: OpenerService, - @inject(GitCommitDetailOpenHandler) protected readonly detailOpenHandler: GitCommitDetailOpenHandler, @inject(ApplicationShell) protected readonly shell: ApplicationShell, @inject(FileSystem) protected readonly fileSystem: FileSystem, - @inject(Git) protected readonly git: Git, @inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService, @inject(WidgetManager) protected readonly widgetManager: WidgetManager, - @inject(GitDiffContribution) protected readonly diffContribution: GitDiffContribution) { + ) { super(); - this.id = GIT_HISTORY_ID; + this.id = SCM_HISTORY_ID; this.scrollContainer = 'git-history-list-container'; - this.title.label = GIT_HISTORY_LABEL; - this.title.caption = GIT_HISTORY_LABEL; - this.title.iconClass = 'fa git-history-tab-icon'; + this.title.label = SCM_HISTORY_LABEL; + this.title.caption = SCM_HISTORY_LABEL; + this.title.iconClass = 'fa scm-history-tab-icon'; this.title.closable = true; this.addClass('theia-git'); this.resetState(); this.cancelIndicator = new CancellationTokenSource(); } + @postConstruct() + protected init(): void { + this.refreshOnRepositoryChange(); + this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => this.refreshOnRepositoryChange())); + } + + protected refreshOnRepositoryChange(): void { + this.toDisposeOnRepositoryChange.dispose(); + + const repository = this.scmService.selectedRepository; + if (repository) { + this.historySupport = repository.input.get(ScmHistorySupport); + this.setContent(this.options); + if (this.historySupport) { + this.toDisposeOnRepositoryChange.push(this.historySupport.onDidChangeHistory(() => this.setContent(this.options))); + } + } else { + this.historySupport = undefined; + } + + // If switching repository, discard options because they are specific to a repository + this.options = {}; + + this.refresh(); + } + + 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 onAfterAttach(msg: Message): void { super.onAfterAttach(msg); - this.addGitListNavigationKeyListeners(this.node); + this.addListNavigationKeyListeners(this.node); // tslint:disable-next-line:no-any this.addEventListener(this.node, 'ps-scroll-y', (e: Event & { target: { scrollTop: number } }) => { if (this.listView && this.listView.list && this.listView.list.Grid) { @@ -108,7 +192,7 @@ export class GitHistoryWidget extends GitNavigableListWidget super.update(); } - async setContent(options?: Git.Options.Log): Promise { + async setContent(options?: HistoryWidgetOptions): Promise { this.resetState(options); if (options && options.uri) { const fileStat = await this.fileSystem.getFileStat(options.uri); @@ -116,83 +200,70 @@ export class GitHistoryWidget extends GitNavigableListWidget } await this.addCommits(options); this.onDataReady(); - if (this.gitNodes.length > 0) { - this.selectNode(this.gitNodes[0]); + if (this.scmNodes.length > 0) { + this.selectNode(this.scmNodes[0]); } } - protected resetState(options?: Git.Options.Log): void { + protected resetState(options?: HistoryWidgetOptions): void { this.options = options || {}; this.status = { state: 'loading' }; - this.gitNodes = []; + this.scmNodes = []; this.hasMoreCommits = true; this.allowScrollToSelected = true; } - protected async addCommits(options?: Git.Options.Log): Promise { - let repository: Repository | undefined; - repository = this.repositoryProvider.findRepositoryOrSelected(options); + 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) { - try { - const currentCommits = this.status.state === 'ready' ? this.status.commits : []; + if (this.historySupport) { + try { + const currentCommits = this.status.state === 'ready' ? this.status.commits : []; - let changes = await this.git.log(repository, options); - if (token.isCancellationRequested || !this.hasMoreCommits) { - return; - } - if (options && ((options.maxCount && changes.length < options.maxCount) || (!options.maxCount && currentCommits))) { - this.hasMoreCommits = false; - } - if (currentCommits.length > 0) { - changes = changes.slice(1); - } - if (changes.length > 0) { - const commits: GitCommitNode[] = []; - for (const commit of changes) { - const fileChangeNodes: GitFileChangeNode[] = []; - const avatarUrl = await this.avatarService.getAvatar(commit.author.email); + let history = await this.historySupport.getCommitHistory(options); + if (token.isCancellationRequested || !this.hasMoreCommits) { + return; + } + + if (options && ((options.maxCount && history.length < options.maxCount) || (!options.maxCount && currentCommits))) { + this.hasMoreCommits = false; + } + if (currentCommits.length > 0) { + history = history.slice(1); + } + const commits: ScmCommitNode[] = []; + for (const commit of history) { + const avatarUrl = await this.avatarService.getAvatar(commit.authorEmail); commits.push({ - authorName: commit.author.name, - authorDate: new Date(commit.author.timestamp), - authorEmail: commit.author.email, - authorDateRelative: commit.authorDateRelative, + commitDetails: commit, authorAvatar: avatarUrl, - commitSha: commit.sha, - commitMessage: commit.summary, - messageBody: commit.body, - fileChangeNodes, - fileChanges: commit.fileChanges, expanded: false, selected: false }); } currentCommits.push(...commits); this.status = { state: 'ready', commits: currentCommits }; - } else if (options && options.uri && repository) { - const pathIsUnderVersionControl = await this.git.lsFiles(repository, options.uri, { errorUnmatch: true }); - if (!pathIsUnderVersionControl) { - this.status = { state: 'error', errorMessage: It is not under version control. }; - } else { - this.status = { state: 'error', errorMessage: No commits have been committed. }; + } catch (error) { + if (options && options.uri && repository) { + this.hasMoreCommits = false; } + this.status = { state: 'error', errorMessage: {error.message} }; } - - } catch (error) { - 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: There is no repository selected in this workspace. }; } } - protected async addOrRemoveFileChangeNodes(commit: GitCommitNode): Promise { - const id = this.gitNodes.findIndex(node => node === commit); + protected async addOrRemoveFileChangeNodes(commit: ScmCommitNode): Promise { + const id = this.scmNodes.findIndex(node => node === commit); if (commit.expanded) { this.removeFileChangeNodes(commit, id); } else { @@ -202,27 +273,32 @@ export class GitHistoryWidget extends GitNavigableListWidget this.update(); } - protected async addFileChangeNodes(commit: GitCommitNode, gitNodesArrayIndex: number): Promise { - if (commit.fileChanges) { - const fileChangeNodes: GitFileChangeNode[] = []; - await Promise.all(commit.fileChanges.map(async fileChange => { + protected async addFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): Promise { + this.scmNodes.splice(scmNodesArrayIndex + 1, 0, ...await this.getFileChangeNodes(commit)); + } + + protected removeFileChangeNodes(commit: ScmCommitNode, scmNodesArrayIndex: number): void { + if (commit.fileChangeNodes) { + this.scmNodes.splice(scmNodesArrayIndex + 1, commit.fileChangeNodes.length); + } + } + + protected async getFileChangeNodes(commitNode: ScmCommitNode): Promise { + if (!commitNode.fileChangeNodes) { + const fileChangeNodes: ScmFileChangeNode[] = []; + await Promise.all(commitNode.commitDetails.fileChanges.map(async fileChange => { const fileChangeUri = new URI(fileChange.uri); const icon = await this.labelProvider.getIcon(fileChangeUri); const label = this.labelProvider.getName(fileChangeUri); const description = this.relativePath(fileChangeUri.parent); const caption = this.computeCaption(fileChange); fileChangeNodes.push({ - ...fileChange, icon, label, description, caption, commitSha: commit.commitSha + fileChange, icon, label, description, caption, commitSha: commitNode.commitDetails.id }); })); - this.gitNodes.splice(gitNodesArrayIndex + 1, 0, ...fileChangeNodes); - } - } - - protected removeFileChangeNodes(commit: GitCommitNode, gitNodesArrayIndex: number): void { - if (commit.fileChanges) { - this.gitNodes.splice(gitNodesArrayIndex + 1, commit.fileChanges.length); + commitNode.fileChangeNodes = fileChangeNodes; } + return commitNode.fileChangeNodes; } storeState(): object { @@ -242,7 +318,7 @@ export class GitHistoryWidget extends GitNavigableListWidget protected onDataReady(): void { if (this.status.state === 'ready') { - this.gitNodes = this.status.commits; + this.scmNodes = this.status.commits; } this.update(); } @@ -265,15 +341,15 @@ export class GitHistoryWidget extends GitNavigableListWidget const relPathEncoded = this.relativePath(this.options.uri); const relPath = relPathEncoded ? `${decodeURIComponent(relPathEncoded)}` : ''; - const repo = this.repositoryProvider.findRepository(new URI(this.options.uri)); - const repoName = repo ? `${new URI(repo.localUri).displayName}` : ''; + const repo = this.scmService.selectedRepository; + const repoName = repo ? `${new URI(repo.provider.rootUri).displayName}` : ''; const relPathAndRepo = [relPath, repoName].filter(Boolean).join(' in '); path = ` for ${relPathAndRepo}`; } content = + header={`There is no history available${path}.`}> {reason} ; break; @@ -309,16 +385,16 @@ export class GitHistoryWidget extends GitNavigableListWidget protected renderCommitList(): React.ReactNode { const list =
- this.listView = (listView || undefined)} - rows={this.gitNodes} + rows={this.scmNodes} hasMoreRows={this.hasMoreCommits} indexOfSelected={this.allowScrollToSelected ? this.indexOfSelected : -1} handleScroll={this.handleScroll} loadMoreRows={this.loadMoreRows} renderCommit={this.renderCommit} renderFileChangeList={this.renderFileChangeList} - > + >
; this.allowScrollToSelected = true; return list; @@ -334,12 +410,12 @@ export class GitHistoryWidget extends GitNavigableListWidget protected doLoadMoreRows(params: IndexRange): Promise { let resolver: () => void; const promise = new Promise(resolve => resolver = resolve); - const lastRow = this.gitNodes[params.stopIndex - 1]; - if (GitCommitNode.is(lastRow)) { - const toRevision = lastRow.commitSha; + const lastRow = this.scmNodes[params.stopIndex - 1]; + if (ScmCommitNode.is(lastRow)) { + const toRevision = lastRow.commitDetails.id; this.addCommits({ range: { toRevision }, - maxCount: GIT_HISTORY_MAX_COUNT, + maxCount: SCM_HISTORY_MAX_COUNT, uri: this.options.uri }).then(() => { this.allowScrollToSelected = false; @@ -350,8 +426,8 @@ export class GitHistoryWidget extends GitNavigableListWidget return promise; } - protected readonly renderCommit = (commit: GitCommitNode) => this.doRenderCommit(commit); - protected doRenderCommit(commit: GitCommitNode): React.ReactNode { + protected readonly renderCommit = (commit: ScmCommitNode) => this.doRenderCommit(commit); + protected doRenderCommit(commit: ScmCommitNode): React.ReactNode { let expansionToggleIcon = 'caret-right'; if (commit && commit.expanded) { expansionToggleIcon = 'caret-down'; @@ -370,8 +446,8 @@ export class GitHistoryWidget extends GitNavigableListWidget } onDoubleClick={ e => { - if (this.singleFileMode && commit.fileChanges && commit.fileChanges.length > 0) { - this.openFile(commit.fileChanges[0], commit.commitSha); + if (this.singleFileMode && commit.fileChangeNodes && commit.fileChangeNodes.length > 0) { + this.openFile(commit.fileChangeNodes[0].fileChange); } e.preventDefault(); } @@ -381,17 +457,17 @@ export class GitHistoryWidget extends GitNavigableListWidget
- {commit.commitMessage} + {commit.commitDetails.summary}
- {commit.authorDateRelative + ' by ' + commit.authorName} + {commit.commitDetails.authorDateRelative + ' by ' + commit.commitDetails.authorName}
this.openDetailWidget(commit)}>
{ !this.singleFileMode ?
-
{(commit.fileChanges && commit.fileChanges.length || commit.fileChangeNodes.length).toString()}
+
{commit.commitDetails.fileChanges.length.toString()}
@@ -401,44 +477,49 @@ export class GitHistoryWidget extends GitNavigableListWidget ; } - protected async openDetailWidget(commit: GitCommitNode): Promise { - const commitDetails = this.detailOpenHandler.getCommitDetailWidgetOptions(commit); - this.detailOpenHandler.open(GitCommitDetailUri.toUri(commit.commitSha), { - ...commitDetails - } as GitCommitDetailOpenerOptions); + protected async openDetailWidget(commitNode: ScmCommitNode): Promise { + const options = { + ...commitNode.commitDetails.commitDetailOptions, + mode: 'reveal' + }; + open( + this.openerService, + commitNode.commitDetails.commitDetailUri, + options + ); } - protected readonly renderFileChangeList = (fileChange: GitFileChangeNode) => this.doRenderFileChangeList(fileChange); - protected doRenderFileChangeList(fileChange: GitFileChangeNode): React.ReactNode { - const fileChangeElement: React.ReactNode = this.renderGitItem(fileChange, fileChange.commitSha || ''); + protected readonly renderFileChangeList = (fileChange: ScmFileChangeNode) => this.doRenderFileChangeList(fileChange); + protected doRenderFileChangeList(fileChange: ScmFileChangeNode): React.ReactNode { + const fileChangeElement: React.ReactNode = this.renderScmItem(fileChange, fileChange.commitSha || ''); return fileChangeElement; } - protected renderGitItem(change: GitFileChangeNode, commitSha: string): React.ReactNode { - return
+ protected renderScmItem(changeNode: ScmFileChangeNode, commitSha: string): React.ReactNode { + return
{ - this.openFile(change, commitSha); + this.openFile(changeNode.fileChange); }} onClick={() => { - this.selectNode(change); + this.selectNode(changeNode); }}> - - {change.label + ' '} - {change.description} + + {changeNode.label + ' '} + {changeNode.description}
{ - change.extraIconClassName ?
+ changeNode.extraIconClassName ?
: '' }
- {this.getStatusCaption(change.status, true).charAt(0)} + title={changeNode.caption} + className={'status staged ' + changeNode.fileChange.getClassNameForStatus().toLowerCase()}> + {changeNode.fileChange.getStatusCaption().charAt(0)}
; } @@ -446,8 +527,8 @@ export class GitHistoryWidget extends GitNavigableListWidget protected navigateLeft(): void { const selected = this.getSelected(); if (selected && this.status.state === 'ready') { - const idx = this.status.commits.findIndex(c => c.commitSha === selected.commitSha); - if (GitCommitNode.is(selected)) { + if (ScmCommitNode.is(selected)) { + const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitDetails.id); if (selected.expanded) { this.addOrRemoveFileChangeNodes(selected); } else { @@ -455,7 +536,8 @@ export class GitHistoryWidget extends GitNavigableListWidget this.selectNode(this.status.commits[idx - 1]); } } - } else if (GitFileChangeNode.is(selected)) { + } else if (ScmFileChangeNode.is(selected)) { + const idx = this.status.commits.findIndex(c => c.commitDetails.id === selected.commitSha); this.selectNode(this.status.commits[idx]); } } @@ -465,7 +547,7 @@ export class GitHistoryWidget extends GitNavigableListWidget protected navigateRight(): void { const selected = this.getSelected(); if (selected) { - if (GitCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) { + if (ScmCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) { this.addOrRemoveFileChangeNodes(selected); } else { this.selectNextNode(); @@ -474,52 +556,42 @@ export class GitHistoryWidget extends GitNavigableListWidget this.update(); } - protected handleListEnter(): void { + protected async handleListEnter(): Promise { const selected = this.getSelected(); if (selected) { - if (GitCommitNode.is(selected)) { + if (ScmCommitNode.is(selected)) { if (this.singleFileMode) { - this.openFile(selected.fileChangeNodes[0], selected.commitSha); + const fileChangeNodes = await this.getFileChangeNodes(selected); + this.openFile(fileChangeNodes[0].fileChange); } else { this.openDetailWidget(selected); } - } else if (GitFileChangeNode.is(selected)) { - this.openFile(selected, selected.commitSha || ''); + } else if (ScmFileChangeNode.is(selected)) { + this.openFile(selected.fileChange); } } this.update(); } - protected openFile(change: GitFileChange, commitSha: string): void { - const uri: URI = new URI(change.uri); - let fromURI = change.oldUri ? new URI(change.oldUri) : uri; // set oldUri on renamed and copied - fromURI = fromURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(commitSha + '~1'); - const toURI = uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(commitSha); - let uriToOpen = uri; - if (change.status === GitFileStatus.Deleted) { - uriToOpen = fromURI; - } else if (change.status === GitFileStatus.New) { - uriToOpen = toURI; - } else { - uriToOpen = DiffUris.encode(fromURI, toURI); - } + protected openFile(change: ScmFileChange): void { + const uriToOpen = change.getUriToOpen(); open(this.openerService, uriToOpen, { mode: 'reveal' }); } } -export namespace GitHistoryList { +export namespace ScmHistoryList { export interface Props { - readonly rows: GitHistoryListNode[] + readonly rows: ScmHistoryListNode[] readonly indexOfSelected: number readonly hasMoreRows: boolean readonly handleScroll: (info: { clientHeight: number; scrollHeight: number; scrollTop: number }) => void // tslint:disable-next-line:no-any readonly loadMoreRows: (params: IndexRange) => Promise - readonly renderCommit: (commit: GitCommitNode) => React.ReactNode - readonly renderFileChangeList: (fileChange: GitFileChangeNode) => React.ReactNode + readonly renderCommit: (commit: ScmCommitNode) => React.ReactNode + readonly renderFileChangeList: (fileChange: ScmFileChangeNode) => React.ReactNode } } -export class GitHistoryList extends React.Component { +export class ScmHistoryList extends React.Component { list: List | undefined; protected readonly checkIfRowIsLoaded = (opts: { index: number }) => this.doCheckIfRowIsLoaded(opts); @@ -590,12 +662,12 @@ export class GitHistoryList extends React.Component { protected renderRow: ListRowRenderer = ({ index, key, style }) => { if (this.checkIfRowIsLoaded({ index })) { const row = this.props.rows[index]; - if (GitCommitNode.is(row)) { + if (ScmCommitNode.is(row)) { const head = this.props.renderCommit(row); return
{head}
; - } else if (GitFileChangeNode.is(row)) { + } else if (ScmFileChangeNode.is(row)) { return
{this.props.renderFileChangeList(row)}
; diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts index 49a5bf57c2e92..f558d2ecee984 100644 --- a/packages/scm/src/browser/scm-contribution.ts +++ b/packages/scm/src/browser/scm-contribution.ts @@ -26,12 +26,14 @@ import { } from '@theia/core/lib/browser'; import { CommandRegistry, Disposable, DisposableCollection, CommandService } from '@theia/core/lib/common'; import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service'; +import { EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser'; import { ScmService } from './scm-service'; import { ScmWidget } from '../browser/scm-widget'; import URI from '@theia/core/lib/common/uri'; import { ScmQuickOpenService } from './scm-quick-open-service'; import { ScmRepository } from './scm-repository'; +export const EDITOR_CONTEXT_MENU_SCM = [...EDITOR_CONTEXT_MENU, '3_scm']; export const SCM_WIDGET_FACTORY_ID = ScmWidget.ID; export const SCM_VIEW_CONTAINER_ID = 'scm-view-container'; export const SCM_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = { @@ -68,7 +70,7 @@ export class ScmContribution extends AbstractViewContribution impleme super({ viewContainerId: SCM_VIEW_CONTAINER_ID, widgetId: SCM_WIDGET_FACTORY_ID, - widgetName: 'SCM', + widgetName: 'Source Control', defaultWidgetOptions: { area: 'left', rank: 300 diff --git a/packages/scm/src/browser/scm-frontend-module.ts b/packages/scm/src/browser/scm-frontend-module.ts index d81c645961edc..b2249bec62dce 100644 --- a/packages/scm/src/browser/scm-frontend-module.ts +++ b/packages/scm/src/browser/scm-frontend-module.ts @@ -24,6 +24,7 @@ import { ScmService } from './scm-service'; import { SCM_WIDGET_FACTORY_ID, ScmContribution, SCM_VIEW_CONTAINER_ID, SCM_VIEW_CONTAINER_TITLE_OPTIONS } from './scm-contribution'; import { ScmWidget } from './scm-widget'; +import { bindScmHistoryModule } from './history/scm-history-frontend-module'; import '../../src/browser/style/index.css'; import { ScmQuickOpenService } from './scm-quick-open-service'; import { bindDirtyDiff } from './dirty-diff/dirty-diff-module'; @@ -61,6 +62,8 @@ export default new ContainerModule(bind => { })).inSingletonScope(); bind(ApplicationShellLayoutMigration).to(ScmLayoutVersion3Migration).inSingletonScope(); + bindScmHistoryModule(bind); + bind(ScmQuickOpenService).toSelf().inSingletonScope(); bindViewContribution(bind, ScmContribution); bind(FrontendApplicationContribution).toService(ScmContribution); diff --git a/packages/scm/src/browser/scm-input.ts b/packages/scm/src/browser/scm-input.ts index fb2792973aae6..70f62cbd5c7a9 100644 --- a/packages/scm/src/browser/scm-input.ts +++ b/packages/scm/src/browser/scm-input.ts @@ -19,6 +19,7 @@ import * as debounce from 'p-debounce'; import { Disposable, DisposableCollection, Emitter } from '@theia/core/lib/common'; import { JSONExt, JSONObject } from '@phosphor/coreutils/lib/json'; +import { interfaces } from 'inversify'; export interface ScmInputIssue { message: string; @@ -32,6 +33,7 @@ export interface ScmInputValidator { export interface ScmInputOptions { placeholder?: string validator?: ScmInputValidator + providerContainer?: interfaces.Container } export interface ScmInputData { @@ -128,4 +130,9 @@ export class ScmInput implements Disposable { } } + get(serviceIdentifier: interfaces.ServiceIdentifier): T | undefined { + return this.options.providerContainer + ? this.options.providerContainer.get(serviceIdentifier) + : undefined; + } } diff --git a/packages/git/src/browser/git-navigable-list-widget.tsx b/packages/scm/src/browser/scm-navigable-list-widget.tsx similarity index 70% rename from packages/git/src/browser/git-navigable-list-widget.tsx rename to packages/scm/src/browser/scm-navigable-list-widget.tsx index e28f3a4d5a0e8..32ed6e1245dad 100644 --- a/packages/git/src/browser/git-navigable-list-widget.tsx +++ b/packages/scm/src/browser/scm-navigable-list-widget.tsx @@ -15,9 +15,9 @@ ********************************************************************************/ import { SELECTED_CLASS, Key, Widget } from '@theia/core/lib/browser'; -import { GitFileStatus, Repository, GitFileChange } from '../common'; +import { ScmFileChange } from './scm-provider'; +import { ScmService } from './scm-service'; import URI from '@theia/core/lib/common/uri'; -import { GitRepositoryProvider } from './git-repository-provider'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { Message } from '@phosphor/messaging'; import { ElementExt } from '@phosphor/domutils'; @@ -26,12 +26,12 @@ import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget'; import * as React from 'react'; @injectable() -export abstract class GitNavigableListWidget extends ReactWidget { +export abstract class ScmNavigableListWidget extends ReactWidget { - protected gitNodes: T[]; + protected scmNodes: T[]; private _scrollContainer: string; - @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider; + @inject(ScmService) protected readonly scmService: ScmService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; constructor() { @@ -74,36 +74,35 @@ export abstract class GitNavigableListWidget e this.update(); } - protected getStatusCaption(status: GitFileStatus, staged?: boolean): string { - return GitFileStatus.toString(status, staged); + protected getStatusCaption(status: ScmFileChange): string { + return status.getStatusCaption(); } - protected getAbbreviatedStatusCaption(status: GitFileStatus, staged?: boolean): string { - return GitFileStatus.toAbbreviation(status, staged); + protected getAbbreviatedStatusCaption(status: ScmFileChange): string { + return status.getStatusAbbreviation(); } protected relativePath(uri: URI | string): string { const parsedUri = typeof uri === 'string' ? new URI(uri) : uri; - const repo = this.repositoryProvider.findRepository(parsedUri); - const relativePath = repo && Repository.relativePath(repo, parsedUri); - if (relativePath) { - return relativePath.toString(); + const repository = this.scmService.selectedRepository; + if (repository) { + const repositoryUri = new URI(repository.provider.rootUri); + const relativePath = repositoryUri.relative(new URI(String(uri))); + if (relativePath) { + return relativePath.toString(); + } } return this.labelProvider.getLongName(parsedUri); } protected getRepositoryLabel(uri: string): string | undefined { - const repository = this.repositoryProvider.findRepository(new URI(uri)); - const isSelectedRepo = this.repositoryProvider.selectedRepository && repository && this.repositoryProvider.selectedRepository.localUri === repository.localUri; - return repository && !isSelectedRepo ? this.labelProvider.getLongName(new URI(repository.localUri)) : undefined; + const repository = this.scmService.findRepository(new URI(uri)); + const isSelectedRepo = this.scmService.selectedRepository && repository && this.scmService.selectedRepository.provider.rootUri === repository.provider.rootUri; + return repository && !isSelectedRepo ? this.labelProvider.getLongName(new URI(repository.provider.rootUri)) : undefined; } - protected computeCaption(fileChange: GitFileChange): string { - let result = `${this.relativePath(fileChange.uri)} - ${this.getStatusCaption(fileChange.status, true)}`; - if (fileChange.oldUri) { - result = `${this.relativePath(fileChange.oldUri)} -> ${result}`; - } - return result; + protected computeCaption(fileChange: ScmFileChange): string { + return fileChange.getCaption(); } protected renderHeaderRow({ name, value, classNames, title }: { name: string, value: React.ReactNode, classNames?: string[], title?: string }): React.ReactNode { @@ -117,7 +116,7 @@ export abstract class GitNavigableListWidget e
; } - protected addGitListNavigationKeyListeners(container: HTMLElement): void { + protected addListNavigationKeyListeners(container: HTMLElement): void { this.addKeyListener(container, Key.ARROW_LEFT, () => this.navigateLeft()); this.addKeyListener(container, Key.ARROW_RIGHT, () => this.navigateRight()); this.addKeyListener(container, Key.ARROW_UP, () => this.navigateUp()); @@ -146,7 +145,7 @@ export abstract class GitNavigableListWidget e } protected getSelected(): T | undefined { - return this.gitNodes ? this.gitNodes.find(c => c.selected || false) : undefined; + return this.scmNodes ? this.scmNodes.find(c => c.selected || false) : undefined; } protected selectNode(node: T): void { @@ -160,23 +159,23 @@ export abstract class GitNavigableListWidget e protected selectNextNode(): void { const idx = this.indexOfSelected; - if (idx >= 0 && idx < this.gitNodes.length - 1) { - this.selectNode(this.gitNodes[idx + 1]); - } else if (this.gitNodes.length > 0 && idx === -1) { - this.selectNode(this.gitNodes[0]); + if (idx >= 0 && idx < this.scmNodes.length - 1) { + this.selectNode(this.scmNodes[idx + 1]); + } else if (this.scmNodes.length > 0 && idx === -1) { + this.selectNode(this.scmNodes[0]); } } protected selectPreviousNode(): void { const idx = this.indexOfSelected; if (idx > 0) { - this.selectNode(this.gitNodes[idx - 1]); + this.selectNode(this.scmNodes[idx - 1]); } } protected get indexOfSelected(): number { - if (this.gitNodes && this.gitNodes.length > 0) { - return this.gitNodes.findIndex(c => c.selected || false); + if (this.scmNodes && this.scmNodes.length > 0) { + return this.scmNodes.findIndex(c => c.selected || false); } return -1; } diff --git a/packages/scm/src/browser/scm-provider.ts b/packages/scm/src/browser/scm-provider.ts index d1b5c94726b0a..cebf81e35c31f 100644 --- a/packages/scm/src/browser/scm-provider.ts +++ b/packages/scm/src/browser/scm-provider.ts @@ -70,11 +70,28 @@ export interface ScmCommand { } export interface ScmCommit { - id: string, // eg Git sha or Mercurial revision number - summary: string, - authorName: string, - authorEmail: string, - authorDateRelative: string + id: string; // eg Git sha or Mercurial revision number + commitDetailUri: URI; + summary: string; + messageBody?: string; + authorName: string; + authorEmail: string; + /** + * The date of the commit in ISO format. + */ + authorTimestamp: string; + authorDateRelative: string; + fileChanges: ScmFileChange[]; + commitDetailOptions: {}; +} + +export interface ScmFileChange { + uri: string; + getCaption(): string; + getStatusCaption(): string; + getStatusAbbreviation(): string; + getClassNameForStatus(): string; + getUriToOpen(): URI; } export interface ScmAmendSupport { diff --git a/packages/scm/src/browser/scm-service.ts b/packages/scm/src/browser/scm-service.ts index 174efbd77bd0c..9346e37156743 100644 --- a/packages/scm/src/browser/scm-service.ts +++ b/packages/scm/src/browser/scm-service.ts @@ -21,6 +21,7 @@ import { injectable, inject } from 'inversify'; import { ScmContextKeyService } from './scm-context-key-service'; import { ScmRepository, ScmProviderOptions } from './scm-repository'; import { ScmCommand, ScmProvider } from './scm-provider'; +import URI from '@theia/core/lib/common/uri'; @injectable() export class ScmService { @@ -79,6 +80,13 @@ export class ScmService { this.fireDidChangeStatusBarCommands(); } + findRepository(uri: URI): ScmRepository | undefined { + const reposSorted = this.repositories.sort( + (ra: ScmRepository, rb: ScmRepository) => rb.provider.rootUri.length - ra.provider.rootUri.length + ); + return reposSorted.find(repo => new URI(repo.provider.rootUri).isEqualOrParent(uri)); + } + registerScmProvider(provider: ScmProvider, options: ScmProviderOptions = {}): ScmRepository { const key = provider.id + ':' + provider.rootUri; if (this._repositories.has(key)) { diff --git a/packages/git/src/browser/style/history.css b/packages/scm/src/browser/style/history.css similarity index 98% rename from packages/git/src/browser/style/history.css rename to packages/scm/src/browser/style/history.css index 6f7368efa0831..f27357e6cf460 100644 --- a/packages/git/src/browser/style/history.css +++ b/packages/scm/src/browser/style/history.css @@ -100,7 +100,6 @@ .theia-git .git-diff-container .listContainer { flex: 1; - position: relative; } .theia-git .git-diff-container .listContainer .commitList { @@ -202,6 +201,6 @@ justify-content: center; } -.git-history-tab-icon::before { +.scm-history-tab-icon::before { content: "\f1da" }