diff --git a/.travis.yml b/.travis.yml index 6b16dc7c56b44..90088e0500997 100644 --- a/.travis.yml +++ b/.travis.yml @@ -50,6 +50,7 @@ cache: - packages/preview/node_modules - packages/process/node_modules - packages/python/node_modules + - packages/scm/node_modules - packages/search-in-workspace/node_modules - packages/task/node_modules - packages/terminal/node_modules diff --git a/examples/browser/package.json b/examples/browser/package.json index 880a35334b1c8..2c7fa34c1502d 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -24,6 +24,7 @@ "@theia/filesystem": "^0.3.19", "@theia/getting-started": "^0.3.19", "@theia/git": "^0.3.19", + "@theia/scm": "^0.3.19", "@theia/java": "^0.3.19", "@theia/java-debug": "^0.3.19", "@theia/json": "^0.3.19", diff --git a/packages/git/package.json b/packages/git/package.json index e399d8f575f86..974ec3b6822a6 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -9,6 +9,7 @@ "@theia/languages": "^0.3.19", "@theia/navigator": "^0.3.19", "@theia/workspace": "^0.3.19", + "@theia/scm": "^0.3.19", "@types/diff": "^3.2.2", "@types/fs-extra": "^4.0.2", "@types/p-queue": "^2.3.1", diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index 526cf3def706f..fb25d1f8998ec 100644 --- a/packages/git/src/browser/git-view-contribution.ts +++ b/packages/git/src/browser/git-view-contribution.ts @@ -15,20 +15,30 @@ ********************************************************************************/ import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { DisposableCollection, CommandRegistry, MenuModelRegistry, CommandContribution, MenuContribution, Command } from '@theia/core'; import { - AbstractViewContribution, StatusBar, StatusBarAlignment, DiffUris, StatusBarEntry, - FrontendApplicationContribution, FrontendApplication, Widget + DisposableCollection, + CommandRegistry, + MenuModelRegistry, + CommandContribution, + MenuContribution, + Command, + Emitter +} from '@theia/core'; +import { + AbstractViewContribution, StatusBar, DiffUris, StatusBarEntry, + FrontendApplicationContribution, FrontendApplication, Widget, StatusBarAlignment } from '@theia/core/lib/browser'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { EditorManager, EditorWidget, EditorOpenerOptions, EditorContextMenu, EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser'; -import { GitFileChange, GitFileStatus } from '../common'; +import { GitFileChange, GitFileStatus, Repository } from '../common'; import { GitWidget } from './git-widget'; import { GitRepositoryTracker } from './git-repository-tracker'; import { GitQuickOpenService, GitAction } from './git-quick-open-service'; import { GitSyncService } from './git-sync-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { GitPrompt } from '../common/git-prompt'; +import { ScmRepository, ScmService, StatusBarCommand } from '@theia/scm/lib/common'; +import { GitRepositoryProvider } from './git-repository-provider'; export const GIT_WIDGET_FACTORY_ID = 'git'; @@ -114,8 +124,14 @@ export class GitViewContribution extends AbstractViewContribution static GIT_REPOSITORY_STATUS = 'git-repository-status'; static GIT_SYNC_STATUS = 'git-sync-status'; + static ID_HANDLE = 0; + protected toDispose = new DisposableCollection(); + private readonly onDidChangeCommandEmitterMap: Map> = new Map(); + private readonly onDidChangeRepositoryEmitterMap: Map> = new Map(); + private dirtyRepositories: Repository[] = []; + @inject(StatusBar) protected readonly statusBar: StatusBar; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(GitQuickOpenService) protected readonly quickOpenService: GitQuickOpenService; @@ -123,6 +139,8 @@ export class GitViewContribution extends AbstractViewContribution @inject(GitSyncService) protected readonly syncService: GitSyncService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(GitPrompt) protected readonly prompt: GitPrompt; + @inject(ScmService) protected readonly scmService: ScmService; + @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider; constructor() { super({ @@ -142,17 +160,28 @@ export class GitViewContribution extends AbstractViewContribution } onStart(): void { + this.repositoryProvider.allRepositories.forEach(repository => this.registerScmProvider(repository)); + this.dirtyRepositories = this.repositoryProvider.allRepositories; this.repositoryTracker.onDidChangeRepository(repository => { if (repository) { if (this.hasMultipleRepositories()) { const path = new URI(repository.localUri).path; - this.statusBar.setElement(GitViewContribution.GIT_SELECTED_REPOSITORY, { - text: `$(database) ${path.base}`, - alignment: StatusBarAlignment.LEFT, - priority: 102, - command: GIT_COMMANDS.CHANGE_REPOSITORY.id, - tooltip: path.toString() - }); + this.scmService.selectedRepositories.forEach(scmRepo => scmRepo.setSelected(false)); + const scmRepository = this.scmService.repositories.find(scmRepo => scmRepo.provider.rootUri === repository.localUri); + if (scmRepository) { + scmRepository.setSelected(true); + } + const onDidChangeCommandEmitter = this.onDidChangeCommandEmitterMap.get(repository.localUri); + if (onDidChangeCommandEmitter) { + onDidChangeCommandEmitter.fire([{ + id: GIT_COMMANDS.CHANGE_REPOSITORY.id, + text: `$(database) ${path.base}`, + alignment: StatusBarAlignment.LEFT, + priority: 102, + command: GIT_COMMANDS.CHANGE_REPOSITORY.id, + tooltip: path.toString() + }]); + } } else { this.statusBar.removeElement(GitViewContribution.GIT_SELECTED_REPOSITORY); } @@ -163,6 +192,7 @@ export class GitViewContribution extends AbstractViewContribution } }); this.repositoryTracker.onGitEvent(event => { + this.checkNewOrRemovedRepositories(); const { status } = event; const branch = status.branch ? status.branch : status.currentHead ? status.currentHead.substring(0, 8) : 'NO-HEAD'; let dirty = ''; @@ -179,15 +209,80 @@ export class GitViewContribution extends AbstractViewContribution dirty = '*'; } } - this.statusBar.setElement(GitViewContribution.GIT_REPOSITORY_STATUS, { - text: `$(code-fork) ${branch}${dirty}`, - alignment: StatusBarAlignment.LEFT, - priority: 101, - command: GIT_COMMANDS.CHECKOUT.id - }); - this.updateSyncStatusBarEntry(); + const onDidChangeCommandEmitter = this.onDidChangeCommandEmitterMap.get(event.source.localUri); + if (onDidChangeCommandEmitter) { + onDidChangeCommandEmitter.fire([{ + id: GIT_COMMANDS.CHECKOUT.id, + text: `$(code-fork) ${branch}${dirty}`, + alignment: StatusBarAlignment.LEFT, + priority: 101, + command: GIT_COMMANDS.CHECKOUT.id + }]); + } + const onDidChangeRepositoryEmitter = this.onDidChangeRepositoryEmitterMap.get(event.source.localUri); + if (onDidChangeRepositoryEmitter) { + onDidChangeRepositoryEmitter.fire(undefined); + } + this.updateSyncStatusBarEntry(event.source.localUri); + }); + this.syncService.onDidChange(() => this.updateSyncStatusBarEntry( + this.repositoryProvider.selectedRepository + ? this.repositoryProvider.selectedRepository.localUri + : undefined) + ); + } + + checkNewOrRemovedRepositories() { + const added = + this.repositoryProvider + .allRepositories + .find(repo => this.dirtyRepositories.every(dirtyRepo => dirtyRepo.localUri !== repo.localUri)); + if (added) { + this.registerScmProvider(added); + } + const removed = + this.dirtyRepositories + .find(dirtyRepo => this.repositoryProvider.allRepositories.every(repo => repo.localUri !== dirtyRepo.localUri)); + if (removed) { + const removedScmRepo = this.scmService.repositories.find(scmRepo => scmRepo.provider.rootUri === removed.localUri); + if (removedScmRepo) { + removedScmRepo.dispose(); + } + } + this.dirtyRepositories = this.repositoryProvider.allRepositories; + } + + registerScmProvider(repository: Repository): ScmRepository { + const uri = repository.localUri; + const disposableCollection = new DisposableCollection(); + const onDidChangeStatusBarCommandsEmitter = new Emitter(); + const onDidChangeResourcesEmitter = new Emitter(); + const onDidChangeRepositoryEmitter = new Emitter(); + this.onDidChangeCommandEmitterMap.set(uri, onDidChangeStatusBarCommandsEmitter); + this.onDidChangeRepositoryEmitterMap.set(uri, onDidChangeRepositoryEmitter); + disposableCollection.push(onDidChangeRepositoryEmitter); + disposableCollection.push(onDidChangeResourcesEmitter); + const dispose = () => { + disposableCollection.dispose(); + this.onDidChangeCommandEmitterMap.delete(uri); + this.onDidChangeRepositoryEmitterMap.delete(uri); + }; + return this.scmService.registerScmProvider({ + label: 'Git', + id: `git_provider_${ GitViewContribution.ID_HANDLE ++ }`, + contextValue: 'git', + onDidChange: onDidChangeRepositoryEmitter.event, + onDidChangeStatusBarCommands: onDidChangeStatusBarCommandsEmitter.event, + onDidChangeResources: onDidChangeRepositoryEmitter.event, + rootUri: uri, + groups: [], + async getOriginalResource() { + return undefined; + }, + dispose(): void { + dispose(); + } }); - this.syncService.onDidChange(() => this.updateSyncStatusBarEntry()); } registerMenus(menus: MenuModelRegistry): void { @@ -412,14 +507,18 @@ export class GitViewContribution extends AbstractViewContribution return this.repositoryTracker.allRepositories.length > 1; } - protected updateSyncStatusBarEntry(): void { + protected updateSyncStatusBarEntry(repositoryUri: string | undefined): void { const entry = this.getStatusBarEntry(); - if (entry) { - this.statusBar.setElement(GitViewContribution.GIT_SYNC_STATUS, { - alignment: StatusBarAlignment.LEFT, - priority: 100, - ...entry - }); + if (entry && repositoryUri) { + const onDidChangeCommandEmitter = this.onDidChangeCommandEmitterMap.get(repositoryUri); + if (onDidChangeCommandEmitter) { + onDidChangeCommandEmitter.fire([{ + id: 'vcs-sync-status', + alignment: StatusBarAlignment.LEFT, + priority: 100, + ...entry + }]); + } } else { this.statusBar.removeElement(GitViewContribution.GIT_SYNC_STATUS); } diff --git a/packages/plugin-ext/package.json b/packages/plugin-ext/package.json index 804c7da56dfa4..d2ece42f81d9a 100644 --- a/packages/plugin-ext/package.json +++ b/packages/plugin-ext/package.json @@ -16,6 +16,7 @@ "@theia/plugin": "^0.3.19", "@theia/preferences": "^0.3.19", "@theia/search-in-workspace": "^0.3.19", + "@theia/scm": "^0.3.19", "@theia/task": "^0.3.19", "@theia/workspace": "^0.3.19", "decompress": "^4.2.0", diff --git a/packages/plugin-ext/src/api/plugin-api.ts b/packages/plugin-ext/src/api/plugin-api.ts index 2fdd7e9299f51..9eb7fdc0a3975 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -16,6 +16,7 @@ /* tslint:disable:no-any */ +import { Plugin as InternalPlugin } from '../api/plugin-api'; import { createProxyIdentifier, ProxyIdentifier } from './rpc-protocol'; import * as theia from '@theia/plugin'; import { PluginLifecycle, PluginModel, PluginMetadata, PluginPackage } from '../common/plugin-protocol'; @@ -62,6 +63,7 @@ import { IJSONSchema, IJSONSchemaSnippet } from '@theia/core/lib/common/json-sch import { DebuggerDescription } from '@theia/debug/lib/common/debug-service'; import { DebugProtocol } from 'vscode-debugprotocol'; import { SymbolInformation } from 'vscode-languageserver-types'; +import { StatusBarCommand } from '@theia/scm/lib/common'; export interface PluginInitData { plugins: PluginMetadata[]; @@ -449,6 +451,87 @@ export interface NotificationExt { $onCancel(id: string): void; } +export interface ScmExt { + createSourceControl(plugin: InternalPlugin, id: string, label: string, rootUri?: theia.Uri): theia.SourceControl; + getLastInputBox(plugin: InternalPlugin): theia.SourceControlInputBox | undefined; + $executeResourceCommand(sourceControlHandle: number, groupHandle: number, resourceHandle: number): Promise; + $provideOriginalResource(sourceControlHandle: number, uri: string, token: CancellationToken): Promise; +} + +export interface ScmMain { + $registerSourceControl(sourceControlHandle: number, id: string, label: string, rootUri?: string): Promise + $updateSourceControl(sourceControlHandle: number, features: SourceControlProviderFeatures): Promise; + $unregisterSourceControl(sourceControlHandle: number): Promise; + + $registerGroup(sourceControlHandle: number, groupHandle: number, id: string, label: string): Promise; + $updateGroup(sourceControlHandle: number, groupHandle: number, features: SourceControlGroupFeatures): Promise; + $updateGroupLabel(sourceControlHandle: number, groupHandle: number, label: string): Promise; + $updateResourceState(sourceControlHandle: number, groupHandle: number, resources: SourceControlResourceState[]): Promise; + $unregisterGroup(sourceControlHandle: number, groupHandle: number): Promise; + + $setInputBoxValue(sourceControlHandle: number, value: string): Promise; + $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): Promise; +} + +export interface SourceControlProviderFeatures { + hasQuickDiffProvider?: boolean; + count?: number; + commitTemplate?: string; + acceptInputCommand?: StatusBarCommand; + statusBarCommands?: StatusBarCommand[]; +} + +export interface SourceControlGroupFeatures { + hideWhenEmpty: boolean | undefined; +} + +export interface SourceControlResourceState { + readonly handle: number + /** + * The uri of the underlying resource inside the workspace. + */ + readonly resourceUri: string; + + /** + * The command which should be run when the resource + * state is open in the Source Control viewlet. + */ + readonly command?: Command; + + /** + * The decorations for this source control + * resource state. + */ + readonly decorations?: SourceControlResourceDecorations; +} + +/** + * The decorations for a [source control resource state](#SourceControlResourceState). + * Can be independently specified for light and dark themes. + */ +export interface SourceControlResourceDecorations { + + /** + * Whether the source control resource state should be striked-through in the UI. + */ + readonly strikeThrough?: boolean; + + /** + * Whether the source control resource state should be faded in the UI. + */ + readonly faded?: boolean; + + /** + * The title for a specific source control resource state. + */ + readonly tooltip?: string; + + /** + * The icon path for a specific source control resource state. + */ + readonly iconPath?: string; +} + export interface NotificationMain { $startProgress(message: string): Promise; $stopProgress(id: string): void; @@ -1008,7 +1091,8 @@ export const PLUGIN_RPC_CONTEXT = { STORAGE_MAIN: createProxyIdentifier('StorageMain'), TASKS_MAIN: createProxyIdentifier('TasksMain'), LANGUAGES_CONTRIBUTION_MAIN: createProxyIdentifier('LanguagesContributionMain'), - DEBUG_MAIN: createProxyIdentifier('DebugMain') + DEBUG_MAIN: createProxyIdentifier('DebugMain'), + SCM_MAIN: createProxyIdentifier('ScmMain') }; export const MAIN_RPC_CONTEXT = { @@ -1030,7 +1114,8 @@ export const MAIN_RPC_CONTEXT = { STORAGE_EXT: createProxyIdentifier('StorageExt'), TASKS_EXT: createProxyIdentifier('TasksExt'), LANGUAGES_CONTRIBUTION_EXT: createProxyIdentifier('LanguagesContributionExt'), - DEBUG_EXT: createProxyIdentifier('DebugExt') + DEBUG_EXT: createProxyIdentifier('DebugExt'), + SCM_EXT: createProxyIdentifier('ScmExt') }; export interface TasksExt { diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index 6219b88e8c958..0b4f736e21873 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -37,6 +37,7 @@ import { TasksMainImpl } from './tasks-main'; import { StorageMainImpl } from './plugin-storage'; import { LanguagesContributionMainImpl } from './languages-contribution-main'; import { DebugMainImpl } from './debug/debug-main'; +import { ScmMainImpl } from './scm-main'; export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { const commandRegistryMain = new CommandRegistryMainImpl(rpc, container); @@ -100,4 +101,7 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const debugMain = new DebugMainImpl(rpc, connectionMain, container); rpc.set(PLUGIN_RPC_CONTEXT.DEBUG_MAIN, debugMain); + + const scmMain = new ScmMainImpl(rpc, container); + rpc.set(PLUGIN_RPC_CONTEXT.SCM_MAIN, scmMain); } diff --git a/packages/plugin-ext/src/main/browser/scm-main.ts b/packages/plugin-ext/src/main/browser/scm-main.ts new file mode 100644 index 0000000000000..d963f6f81673a --- /dev/null +++ b/packages/plugin-ext/src/main/browser/scm-main.ts @@ -0,0 +1,338 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. 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 { + MAIN_RPC_CONTEXT, + ScmExt, + SourceControlGroupFeatures, + ScmMain, + SourceControlProviderFeatures, + SourceControlResourceState +} from '../../api/plugin-api'; +import { + ScmProvider, + ScmRepository, + ScmResource, + ScmResourceDecorations, + ScmResourceGroup, + ScmService, + StatusBarCommand +} from '@theia/scm/lib/common'; +import { RPCProtocol } from '../../api/rpc-protocol'; +import { interfaces } from 'inversify'; +import { CancellationToken, DisposableCollection, Emitter, Event } from '@theia/core'; +import URI from '@theia/core/lib/common/uri'; + +export class ScmMainImpl implements ScmMain { + private readonly proxy: ScmExt; + private readonly scmService: ScmService; + private readonly scmRepositoryMap: Map; + + constructor(rpc: RPCProtocol, container: interfaces.Container) { + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.SCM_EXT); + this.scmService = container.get(ScmService); + this.scmRepositoryMap = new Map(); + } + + async $registerSourceControl(sourceControlHandle: number, id: string, label: string, rootUri?: string): Promise { + const provider: ScmProvider = new ScmProviderImpl(this.proxy, sourceControlHandle, id, label, rootUri); + this.scmRepositoryMap.set(sourceControlHandle, this.scmService.registerScmProvider(provider)); + } + + async $updateSourceControl(sourceControlHandle: number, features: SourceControlProviderFeatures): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + const provider = repository.provider as ScmProviderImpl; + provider.updateSourceControl(features); + } + } + + async $unregisterSourceControl(sourceControlHandle: number): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + repository.dispose(); + this.scmRepositoryMap.delete(sourceControlHandle); + } + } + + async $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + repository.input.placeholder = placeholder; + } + } + + async $setInputBoxValue(sourceControlHandle: number, value: string): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + repository.input.value = value; + } + } + + async $registerGroup(sourceControlHandle: number, groupHandle: number, id: string, label: string): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + const provider = repository.provider as ScmProviderImpl; + provider.registerGroup(groupHandle, id, label); + } + } + + async $unregisterGroup(sourceControlHandle: number, groupHandle: number): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + const provider = repository.provider as ScmProviderImpl; + provider.unregisterGroup(groupHandle); + } + } + + async $updateGroup(sourceControlHandle: number, groupHandle: number, features: SourceControlGroupFeatures): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + const provider = repository.provider as ScmProviderImpl; + provider.updateGroup(groupHandle, features); + } + } + + async $updateGroupLabel(sourceControlHandle: number, groupHandle: number, label: string): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + const provider = repository.provider as ScmProviderImpl; + provider.updateGroupLabel(groupHandle, label); + } + } + + async $updateResourceState(sourceControlHandle: number, groupHandle: number, resources: SourceControlResourceState[]): Promise { + const repository = this.scmRepositoryMap.get(sourceControlHandle); + if (repository) { + const provider = repository.provider as ScmProviderImpl; + provider.updateGroupResourceStates(sourceControlHandle, groupHandle, resources); + } + } +} +class ScmProviderImpl implements ScmProvider { + private static ID_HANDLE = 0; + + private onDidChangeEmitter = new Emitter(); + private onDidChangeResourcesEmitter = new Emitter(); + private onDidChangeCommitTemplateEmitter = new Emitter(); + private onDidChangeStatusBarCommandsEmitter = new Emitter(); + private features: SourceControlProviderFeatures = {}; + private groupsMap: Map = new Map(); + private disposableCollection: DisposableCollection = new DisposableCollection(); + + constructor( + private proxy: ScmExt, + private handle: number, + private _contextValue: string, + private _label: string, + private _rootUri: string | undefined, + ) { + this.disposableCollection.push(this.onDidChangeEmitter); + this.disposableCollection.push(this.onDidChangeResourcesEmitter); + this.disposableCollection.push(this.onDidChangeCommitTemplateEmitter); + this.disposableCollection.push(this.onDidChangeStatusBarCommandsEmitter); + } + + private _id = `scm${ScmProviderImpl.ID_HANDLE++}`; + + get id(): string { + return this._id; + } + get groups(): ScmResourceGroup[] { + return Array.from(this.groupsMap.values()); + } + + get label(): string { + return this._label; + } + + get rootUri(): string | undefined { + return this._rootUri; + } + + get contextValue(): string { + return this._contextValue; + } + + get onDidChangeResources(): Event { + return this.onDidChangeResourcesEmitter.event; + } + + get commitTemplate(): string | undefined { + return this.features.commitTemplate; + } + + get acceptInputCommand(): StatusBarCommand | undefined { + return this.features.acceptInputCommand; + } + + get statusBarCommands(): StatusBarCommand[] | undefined { + return this.features.statusBarCommands; + } + + get count(): number | undefined { + return this.features.count; + } + + get onDidChangeCommitTemplate(): Event { + return this.onDidChangeCommitTemplateEmitter.event; + } + + get onDidChangeStatusBarCommands(): Event { + return this.onDidChangeStatusBarCommandsEmitter.event; + } + + get onDidChange(): Event { + return this.onDidChangeEmitter.event; + } + + dispose(): void { + this.disposableCollection.dispose(); + } + + updateSourceControl(features: SourceControlProviderFeatures): void { + this.features = features; + this.onDidChangeEmitter.fire(undefined); + + if (features.commitTemplate) { + this.onDidChangeCommitTemplateEmitter.fire(features.commitTemplate); + } + + if (features.statusBarCommands) { + this.onDidChangeStatusBarCommandsEmitter.fire(features.statusBarCommands); + } + } + + async getOriginalResource(uri: URI): Promise { + if (this.features.hasQuickDiffProvider) { + const result = await this.proxy.$provideOriginalResource(this.handle, uri.toString(), CancellationToken.None); + if (result) { + return new URI(result.path); + } + } + } + + registerGroup(groupHandle: number, id: string, label: string): void { + const group = new ResourceGroup( + this, + { hideWhenEmpty: undefined }, + label, + id + ); + + this.groupsMap.set(groupHandle, group); + } + + unregisterGroup(groupHandle: number): void { + this.groupsMap.delete(groupHandle); + } + + updateGroup(groupHandle: number, features: SourceControlGroupFeatures): void { + const group = this.groupsMap.get(groupHandle); + if (group) { + (group as ResourceGroup).updateGroup(features); + } + } + + updateGroupLabel(groupHandle: number, label: string): void { + const group = this.groupsMap.get(groupHandle); + if (group) { + (group as ResourceGroup).updateGroupLabel(label); + } + } + + updateGroupResourceStates(sourceControlHandle: number, groupHandle: number, resources: SourceControlResourceState[]): void { + const group = this.groupsMap.get(groupHandle); + if (group) { + (group as ResourceGroup).updateResources(resources.map(resource => { + let scmDecorations; + const decorations = resource.decorations; + if (decorations) { + scmDecorations = { + icon: new URI(decorations.iconPath), + tooltip: decorations.tooltip, + strikeThrough: decorations.strikeThrough, + faded: decorations.faded + }; + } + return new ScmResourceImpl( + this.proxy, + sourceControlHandle, + groupHandle, + resource.handle, + new URI(resource.resourceUri), + group, + scmDecorations); + })); + } + } +} + +class ResourceGroup implements ScmResourceGroup { + + private _resources: ScmResource[] = []; + private onDidChangeEmitter = new Emitter(); + + constructor( + public provider: ScmProvider, + public features: SourceControlGroupFeatures, + public label: string, + public id: string + ) { + } + + get resources() { + return this._resources; + } + get onDidChange(): Event { + return this.onDidChangeEmitter.event; + } + + get hideWhenEmpty(): boolean | undefined { + return this.features.hideWhenEmpty; + } + + updateGroup(features: SourceControlGroupFeatures): void { + this.features = features; + this.onDidChangeEmitter.fire(undefined); + } + + updateGroupLabel(label: string): void { + this.label = label; + this.onDidChangeEmitter.fire(undefined); + } + + updateResources(resources: ScmResource[]) { + this._resources = resources; + this.onDidChangeEmitter.fire(undefined); + } +} + +class ScmResourceImpl implements ScmResource { + constructor( + private proxy: ScmExt, + private handle: number, + private sourceControlHandle: number, + private groupHandle: number, + public sourceUri: URI, + public resourceGroup: ScmResourceGroup, + public decorations?: ScmResourceDecorations + ) { } + + open(): Promise { + return this.proxy.$executeResourceCommand(this.sourceControlHandle, this.groupHandle, this.handle); + } +} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 2e47effa84fcd..1d3d07fc95794 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -117,6 +117,7 @@ import { ConnectionExtImpl } from './connection-ext'; import { WebviewsExtImpl } from './webviews'; import { TasksExtImpl } from './tasks/tasks'; import { DebugExtImpl } from './node/debug/debug'; +import { ScmExtImpl } from './scm'; export function createAPIFactory( rpc: RPCProtocol, @@ -146,6 +147,7 @@ export function createAPIFactory( const tasksExt = rpc.set(MAIN_RPC_CONTEXT.TASKS_EXT, new TasksExtImpl(rpc)); const connectionExt = rpc.set(MAIN_RPC_CONTEXT.CONNECTION_EXT, new ConnectionExtImpl(rpc)); const languagesContributionExt = rpc.set(MAIN_RPC_CONTEXT.LANGUAGES_CONTRIBUTION_EXT, new LanguagesContributionExtImpl(rpc, connectionExt)); + const scmExt = rpc.set(MAIN_RPC_CONTEXT.SCM_EXT, new ScmExtImpl(rpc, commandRegistry)); rpc.set(MAIN_RPC_CONTEXT.DEBUG_EXT, debugExt); return function (plugin: InternalPlugin): typeof theia { @@ -603,6 +605,20 @@ export function createAPIFactory( } }; + const scm: typeof theia.scm = { + get inputBox(): theia.SourceControlInputBox { + const inputBox = scmExt.getLastInputBox(plugin); + if (inputBox) { + return inputBox; + } else { + throw new Error('Input box not found!'); + } + }, + createSourceControl(id: string, label: string, rootUri?: Uri): theia.SourceControl { + return scmExt.createSourceControl(plugin, id, label, rootUri); + } + }; + return { version: require('../../package.json').version, commands, @@ -614,6 +630,7 @@ export function createAPIFactory( plugins, debug, tasks, + scm, // Types StatusBarAlignment: StatusBarAlignment, Disposable: Disposable, diff --git a/packages/plugin-ext/src/plugin/scm.ts b/packages/plugin-ext/src/plugin/scm.ts new file mode 100644 index 0000000000000..12d66b8c9ff7a --- /dev/null +++ b/packages/plugin-ext/src/plugin/scm.ts @@ -0,0 +1,300 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. 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 * as theia from '@theia/plugin'; +import { CommandRegistryExt, Plugin as InternalPlugin, PLUGIN_RPC_CONTEXT, ScmExt, ScmMain } from '../api/plugin-api'; +import { RPCProtocol } from '../api/rpc-protocol'; +import { StatusBarCommand } from '@theia/scm/lib/common/scm'; +import { CancellationToken } from '@theia/core'; +import { UriComponents } from '../common/uri-components'; +import URI from '@theia/core/lib/common/uri'; + +export class ScmExtImpl implements ScmExt { + private handle: number = 0; + private readonly proxy: ScmMain; + private readonly sourceControlMap: Map = new Map(); + private readonly sourceControlsByPluginMap: Map = new Map(); + + constructor(readonly rpc: RPCProtocol, private readonly commands: CommandRegistryExt) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.SCM_MAIN); + } + + createSourceControl(plugin: InternalPlugin, id: string, label: string, rootUri?: theia.Uri): theia.SourceControl { + const sourceControl = new SourceControlImpl(this.proxy, this.commands, id, label, rootUri); + this.sourceControlMap.set(this.handle++, sourceControl); + const sourceControls = this.sourceControlsByPluginMap.get(plugin.model.id) || []; + sourceControls.push(sourceControl); + this.sourceControlsByPluginMap.set(plugin.model.id, sourceControls); + return sourceControl; + } + + getLastInputBox(plugin: InternalPlugin): theia.SourceControlInputBox | undefined { + const sourceControls = this.sourceControlsByPluginMap.get(plugin.model.id); + const sourceControl = sourceControls && sourceControls[sourceControls.length - 1]; + const inputBox = sourceControl && sourceControl.inputBox; + return inputBox; + } + + async $executeResourceCommand(sourceControlHandle: number, groupHandle: number, resourceHandle: number): Promise { + const sourceControl = this.sourceControlMap.get(sourceControlHandle); + if (sourceControl) { + const group = (sourceControl as SourceControlImpl).getResourceGroup(groupHandle); + if (group) { + (group as SourceControlResourceGroupImpl).executeResourceCommand(resourceHandle); + } + } + } + + async $provideOriginalResource(sourceControlHandle: number, uri: string, token: CancellationToken): Promise { + const sourceControl = this.sourceControlMap.get(sourceControlHandle); + console.log(sourceControl); + if (sourceControl && sourceControl.quickDiffProvider && sourceControl.quickDiffProvider.provideOriginalResource) { + // tslint:disable-next-line:no-any + const _uri: any = new URI(uri); + _uri.fsPath = uri; + return sourceControl.quickDiffProvider.provideOriginalResource(_uri, token); + } + } +} + +class InputBoxImpl implements theia.SourceControlInputBox { + private _placeholder: string; + private _value: string; + + constructor(private proxy: ScmMain, private sourceControlHandle: number) { + } + + get value(): string { + return this._value; + } + + set value(value: string) { + this._value = value; + this.proxy.$setInputBoxValue(this.sourceControlHandle, value); + } + + get placeholder(): string { + return this._placeholder; + } + + set placeholder(placeholder: string) { + this._placeholder = placeholder; + this.proxy.$setInputBoxPlaceholder(this.sourceControlHandle, placeholder); + } +} + +class SourceControlImpl implements theia.SourceControl { + private static _handle: number = 0; + private handle = SourceControlImpl._handle ++; + + private readonly resourceGroupsMap: Map = new Map(); + + private readonly _inputBox: theia.SourceControlInputBox; + private _count: number | undefined; + private _quickDiffProvider: theia.QuickDiffProvider | undefined; + private _commitTemplate: string | undefined; + private _acceptInputCommand: theia.Command | undefined; + private _statusBarCommands: theia.Command[] | undefined; + + constructor( + private proxy: ScmMain, + private commands: CommandRegistryExt, + private _id: string, + private _label: string, + private _rootUri?: theia.Uri + ) { + this._inputBox = new InputBoxImpl(proxy, this.handle); + this.proxy.$registerSourceControl(this.handle, _id, _label, _rootUri ? _rootUri.path : undefined); + } + + get id(): string { + return this._id; + } + + get label(): string { + return this._label; + } + + get rootUri(): theia.Uri | undefined { + return this._rootUri; + } + + createResourceGroup(id: string, label: string): theia.SourceControlResourceGroup { + const sourceControlResourceGroup = new SourceControlResourceGroupImpl(this.proxy, this.commands, this.handle, id, label); + this.resourceGroupsMap.set(this.handle, sourceControlResourceGroup); + return sourceControlResourceGroup; + } + + get inputBox(): theia.SourceControlInputBox { + return this._inputBox; + } + + get count(): number | undefined { + return this._count; + } + + set count(count: number | undefined) { + if (this._count !== count) { + this._count = count; + this.proxy.$updateSourceControl(this.handle, { count }); + } + } + + get quickDiffProvider(): theia.QuickDiffProvider | undefined { + return this._quickDiffProvider; + } + + set quickDiffProvider(quickDiffProvider: theia.QuickDiffProvider | undefined) { + this._quickDiffProvider = quickDiffProvider; + this.proxy.$updateSourceControl(this.handle, { hasQuickDiffProvider: !!quickDiffProvider }); + } + + get commitTemplate(): string | undefined { + return this._commitTemplate; + } + + set commitTemplate(commitTemplate: string | undefined) { + this._commitTemplate = commitTemplate; + this.proxy.$updateSourceControl(this.handle, { commitTemplate }); + } + + dispose(): void { + this.proxy.$unregisterSourceControl(this.handle); + } + + get acceptInputCommand(): theia.Command | undefined { + return this._acceptInputCommand; + } + + set acceptInputCommand(acceptInputCommand: theia.Command | undefined) { + this._acceptInputCommand = acceptInputCommand; + + if (acceptInputCommand) { + const command: StatusBarCommand = { + id: acceptInputCommand.id, + text: acceptInputCommand.label ? acceptInputCommand.label : '', + alignment: 1 + }; + this.proxy.$updateSourceControl(this.handle, { acceptInputCommand: command }); + } + } + + get statusBarCommands(): theia.Command[] | undefined { + return this._statusBarCommands; + } + + set statusBarCommands(statusBarCommands: theia.Command[] | undefined) { + this._statusBarCommands = statusBarCommands; + if (statusBarCommands) { + const commands = statusBarCommands.map(statusBarCommand => { + const command = { + id: statusBarCommand.id, + text: statusBarCommand.label ? statusBarCommand.label : '', + alignment: 0 + }; + return command; + }); + this.proxy.$updateSourceControl(this.handle, {statusBarCommands: commands}); + } + } + + getResourceGroup(handle: number): theia.SourceControlResourceGroup | undefined { + return this.resourceGroupsMap.get(handle); + } +} + +class SourceControlResourceGroupImpl implements theia.SourceControlResourceGroup { + + private static _handle: number = 0; + private static _resourceHandle: number = 0; + private handle = SourceControlResourceGroupImpl._handle ++; + private _hideWhenEmpty: boolean | undefined = undefined; + private _resourceStates: theia.SourceControlResourceState[] = []; + private resourceStatesMap: Map = new Map(); + + constructor( + private proxy: ScmMain, + private commands: CommandRegistryExt, + private sourceControlHandle: number, + private _id: string, + private _label: string, + ) { + this.proxy.$registerGroup(sourceControlHandle, this.handle, _id, _label); + } + + get id(): string { + return this._id; + } + + get label(): string { + return this._label; + } + + set label(label: string) { + this._label = label; + this.proxy.$updateGroupLabel(this.sourceControlHandle, this.handle, label); + } + + get hideWhenEmpty(): boolean | undefined { + return this._hideWhenEmpty; + } + + set hideWhenEmpty(hideWhenEmpty: boolean | undefined) { + this._hideWhenEmpty = hideWhenEmpty; + this.proxy.$updateGroup(this.sourceControlHandle, this.handle, {hideWhenEmpty}); + } + + get resourceStates(): theia.SourceControlResourceState[] { + return this._resourceStates; + } + + set resourceStates(resources: theia.SourceControlResourceState[]) { + this._resourceStates = resources; + this.resourceStatesMap.clear(); + this.proxy.$updateResourceState(this.sourceControlHandle, this.handle, resources.map(resourceState => { + const handle = SourceControlResourceGroupImpl._resourceHandle ++; + let command; + let decorations; + if (resourceState.command) { + const { id, label, tooltip } = resourceState.command; + command = { id, title: label ? label : '', tooltip }; + } + if (resourceState.decorations) { + const { strikeThrough, faded, tooltip, light, dark } = resourceState.decorations; + const theme = light || dark; + let iconPath; + if (theme && theme.iconPath) { + iconPath = typeof theme.iconPath === 'string' ? theme.iconPath : theme.iconPath.path; + } + decorations = { strikeThrough, faded, tooltip, iconPath }; + } + this.resourceStatesMap.set(handle, resourceState); + return { handle, resourceUri: resourceState.resourceUri.path, command, decorations }; + })); + } + + async executeResourceCommand(stateHandle: number): Promise { + const state = this.resourceStatesMap.get(stateHandle); + if (state && state.command) { + const command = state.command; + await this.commands.$executeCommand(command.id, command.arguments); + } + } + + dispose(): void { + this.proxy.$unregisterGroup(this.sourceControlHandle, this.handle); + } +} diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index fe140a45f74ff..cc82e915529f2 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -6330,6 +6330,235 @@ declare module '@theia/plugin' { provideDocumentHighlights(document: TextDocument, position: Position, token: CancellationToken | undefined): ProviderResult; } + /** + * Represents the input box in the Source Control viewlet. + */ + export interface SourceControlInputBox { + + /** + * Setter and getter for the contents of the input box. + */ + value: string; + + /** + * A string to show as place holder in the input box to guide the user. + */ + placeholder: string; + } + + interface QuickDiffProvider { + + /** + * Provide a [uri](#Uri) to the original resource of any given resource uri. + * + * @param uri The uri of the resource open in a text editor. + * @param token A cancellation token. + * @return A thenable that resolves to uri of the matching original resource. + */ + provideOriginalResource?(uri: Uri, token: CancellationToken): ProviderResult; + } + + /** + * The theme-aware decorations for a + * [source control resource state](#SourceControlResourceState). + */ + export interface SourceControlResourceThemableDecorations { + + /** + * The icon path for a specific + * [source control resource state](#SourceControlResourceState). + */ + readonly iconPath?: string | Uri; + } + + /** + * The decorations for a [source control resource state](#SourceControlResourceState). + * Can be independently specified for light and dark themes. + */ + export interface SourceControlResourceDecorations extends SourceControlResourceThemableDecorations { + + /** + * Whether the [source control resource state](#SourceControlResourceState) should + * be striked-through in the UI. + */ + readonly strikeThrough?: boolean; + + /** + * Whether the [source control resource state](#SourceControlResourceState) should + * be faded in the UI. + */ + readonly faded?: boolean; + + /** + * The title for a specific + * [source control resource state](#SourceControlResourceState). + */ + readonly tooltip?: string; + + /** + * The light theme decorations. + */ + readonly light?: SourceControlResourceThemableDecorations; + + /** + * The dark theme decorations. + */ + readonly dark?: SourceControlResourceThemableDecorations; + } + + /** + * An source control resource state represents the state of an underlying workspace + * resource within a certain [source control group](#SourceControlResourceGroup). + */ + export interface SourceControlResourceState { + + /** + * The [uri](#Uri) of the underlying resource inside the workspace. + */ + readonly resourceUri: Uri; + + /** + * The [command](#Command) which should be run when the resource + * state is open in the Source Control viewlet. + */ + readonly command?: Command; + + /** + * The [decorations](#SourceControlResourceDecorations) for this source control + * resource state. + */ + readonly decorations?: SourceControlResourceDecorations; + } + + /** + * A source control resource group is a collection of + * [source control resource states](#SourceControlResourceState). + */ + export interface SourceControlResourceGroup { + + /** + * The id of this source control resource group. + */ + readonly id: string; + + /** + * The label of this source control resource group. + */ + label: string; + + /** + * Whether this source control resource group is hidden when it contains + * no [source control resource states](#SourceControlResourceState). + */ + hideWhenEmpty?: boolean; + + /** + * This group's collection of + * [source control resource states](#SourceControlResourceState). + */ + resourceStates: SourceControlResourceState[]; + + /** + * Dispose this source control resource group. + */ + dispose(): void; + } + + /** + * An source control is able to provide [resource states](#SourceControlResourceState) + * to the editor and interact with the editor in several source control related ways. + */ + export interface SourceControl { + + /** + * The id of this source control. + */ + readonly id: string; + + /** + * The human-readable label of this source control. + */ + readonly label: string; + + /** + * The (optional) Uri of the root of this source control. + */ + readonly rootUri: Uri | undefined; + + /** + * The [input box](#SourceControlInputBox) for this source control. + */ + readonly inputBox: SourceControlInputBox; + + /** + * The UI-visible count of [resource states](#SourceControlResourceState) of + * this source control. + * + * Equals to the total number of [resource state](#SourceControlResourceState) + * of this source control, if undefined. + */ + count?: number; + + /** + * An optional [quick diff provider](#QuickDiffProvider). + */ + quickDiffProvider?: QuickDiffProvider; + + /** + * Optional commit template string. + * + * The Source Control viewlet will populate the Source Control + * input with this value when appropriate. + */ + commitTemplate?: string; + + /** + * Optional accept input command. + * + * This command will be invoked when the user accepts the value + * in the Source Control input. + */ + acceptInputCommand?: Command; + + /** + * Optional status bar commands. + * + * These commands will be displayed in the editor's status bar. + */ + statusBarCommands?: Command[]; + + /** + * Create a new [resource group](#SourceControlResourceGroup). + */ + createResourceGroup(id: string, label: string): SourceControlResourceGroup; + + /** + * Dispose this source control. + */ + dispose(): void; + } + + export namespace scm { + + /** + * ~~The [input box](#SourceControlInputBox) for the last source control + * created by the extension.~~ + * + * @deprecated Use SourceControl.inputBox instead + */ + export const inputBox: SourceControlInputBox; + + /** + * Creates a new [source control](#SourceControl) instance. + * + * @param id An `id` for the source control. Something short, eg: `git`. + * @param label A human-readable string for the source control. Eg: `Git`. + * @param rootUri An optional Uri of the root of the source control. Eg: `Uri.parse(workspaceRoot)`. + * @return An instance of [source control](#SourceControl). + */ + export function createSourceControl(id: string, label: string, rootUri?: Uri): SourceControl; + } + /** * Configuration for a debug session. */ diff --git a/packages/scm/compile.tsconfig.json b/packages/scm/compile.tsconfig.json new file mode 100644 index 0000000000000..b8b72b49c8822 --- /dev/null +++ b/packages/scm/compile.tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/scm/package.json b/packages/scm/package.json new file mode 100644 index 0000000000000..1e4537701e1dd --- /dev/null +++ b/packages/scm/package.json @@ -0,0 +1,46 @@ +{ + "name": "@theia/scm", + "version": "0.3.19", + "description": "Theia - Source control Extension", + "dependencies": { + "@theia/core": "^0.3.19" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/scm-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/theia-ide/theia.git" + }, + "bugs": { + "url": "https://github.com/theia-ide/theia/issues" + }, + "homepage": "https://github.com/theia-ide/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "prepare": "yarn run clean && yarn run build", + "clean": "theiaext clean", + "build": "theiaext build", + "watch": "theiaext watch", + "test": "theiaext test", + "docs": "theiaext docs" + }, + "devDependencies": { + "@theia/ext-scripts": "^0.3.19" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts new file mode 100644 index 0000000000000..343f0dc4af777 --- /dev/null +++ b/packages/scm/src/browser/scm-contribution.ts @@ -0,0 +1,37 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. 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 { injectable, inject } from 'inversify'; +import { FrontendApplicationContribution, StatusBar } from '@theia/core/lib/browser'; +import { ScmService, StatusBarCommand } from '../common/scm'; + +@injectable() +export class ScmContribution implements FrontendApplicationContribution { + @inject(StatusBar) protected readonly statusBar: StatusBar; + @inject(ScmService) protected readonly scmService: ScmService; + onStart(): void { + const refresh = (commands: StatusBarCommand[]) => { + commands.forEach(command => { + this.statusBar.setElement(command.id, command); + }); + }; + this.scmService.onDidAddRepository(repository => { + const onDidChangeStatusBarCommands = repository.provider.onDidChangeStatusBarCommands; + if (onDidChangeStatusBarCommands) { + onDidChangeStatusBarCommands(commands => refresh(commands)); + } + }); + } +} diff --git a/packages/scm/src/browser/scm-frontend-module.ts b/packages/scm/src/browser/scm-frontend-module.ts new file mode 100644 index 0000000000000..5771dd507635e --- /dev/null +++ b/packages/scm/src/browser/scm-frontend-module.ts @@ -0,0 +1,28 @@ +/******************************************************************************** + * Copyright (C) 2017 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 { ContainerModule } from 'inversify'; +import { ScmContribution } from './scm-contribution'; +import { ScmServiceImpl } from './scmService'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { ScmService } from '../common/scm'; + +export default new ContainerModule(bind => { + bind(ScmContribution).toSelf().inSingletonScope(); + bind(ScmService).to(ScmServiceImpl).inSingletonScope(); + + bind(FrontendApplicationContribution).toService(ScmContribution); +}); diff --git a/packages/scm/src/browser/scmService.ts b/packages/scm/src/browser/scmService.ts new file mode 100644 index 0000000000000..52892c37ab510 --- /dev/null +++ b/packages/scm/src/browser/scmService.ts @@ -0,0 +1,187 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. 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 { + InputValidator, + ScmInput, + ScmProvider, + ScmRepository, + ScmService +} from '../common/scm'; +import { Disposable, Emitter, Event } from '@theia/core/lib/common'; +import { injectable } from 'inversify'; + +@injectable() +export class ScmServiceImpl implements ScmService { + private providerIds = new Set(); + private _repositories: ScmRepository[] = []; + private _selectedRepositories: ScmRepository[] = []; + + private onDidChangeSelectedRepositoriesEmitter = new Emitter(); + private onDidAddProviderEmitter = new Emitter(); + private onDidRemoveProviderEmitter = new Emitter(); + + readonly onDidChangeSelectedRepositories: Event = this.onDidChangeSelectedRepositoriesEmitter.event; + + get repositories(): ScmRepository[] { + return [...this._repositories]; + } + + get selectedRepositories(): ScmRepository[] { + return [...this._selectedRepositories]; + } + + get onDidAddRepository(): Event { + return this.onDidAddProviderEmitter.event; + } + + get onDidRemoveRepository(): Event { return this.onDidRemoveProviderEmitter.event; } + + registerScmProvider(provider: ScmProvider): ScmRepository { + + if (this.providerIds.has(provider.id)) { + throw new Error(`SCM Provider ${provider.id} already exists.`); + } + + this.providerIds.add(provider.id); + + function toDisposable(fn: () => void): Disposable { + return { dispose() { fn(); } }; + } + const disposable: Disposable = toDisposable(() => { + const index = this._repositories.indexOf(repository); + if (index < 0) { + return; + } + selectedDisposable.dispose(); + this.providerIds.delete(provider.id); + this._repositories.splice(index, 1); + this.onDidRemoveProviderEmitter.fire(repository); + this.onDidChangeSelection(); + }); + + const repository = new SCMRepositoryImpl(provider, disposable); + const selectedDisposable = repository.onDidChangeSelection(this.onDidChangeSelection, this); + + this._repositories.push(repository); + this.onDidAddProviderEmitter.fire(repository); + + // automatically select the first repository + if (this._repositories.length === 1) { + repository.setSelected(true); + } + + return repository; + } + + private onDidChangeSelection(): void { + this._selectedRepositories = this._repositories.filter(r => r.selected); + this.onDidChangeSelectedRepositoriesEmitter.fire(this.selectedRepositories); + } +} + +class SCMRepositoryImpl implements ScmRepository { + + private _onDidFocus = new Emitter(); + readonly onDidFocus: Event = this._onDidFocus.event; + + private _selected = false; + get selected(): boolean { + return this._selected; + } + + private _onDidChangeSelection = new Emitter(); + readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; + + readonly input: ScmInput = new SCMInputImpl(); + + constructor( + public readonly provider: ScmProvider, + private disposable: Disposable + ) { } + + focus(): void { + this._onDidFocus.fire(undefined); + } + + setSelected(selected: boolean): void { + this._selected = selected; + this._onDidChangeSelection.fire(selected); + } + + dispose(): void { + this.disposable.dispose(); + this.provider.dispose(); + } +} + +class SCMInputImpl implements ScmInput { + + private _value = ''; + + get value(): string { + return this._value; + } + + set value(value: string) { + this._value = value; + this._onDidChange.fire(value); + } + + private _onDidChange = new Emitter(); + get onDidChange(): Event { return this._onDidChange.event; } + + private _placeholder = ''; + + get placeholder(): string { + return this._placeholder; + } + + set placeholder(placeholder: string) { + this._placeholder = placeholder; + this._onDidChangePlaceholder.fire(placeholder); + } + + private _onDidChangePlaceholder = new Emitter(); + get onDidChangePlaceholder(): Event { return this._onDidChangePlaceholder.event; } + + private _visible = true; + + get visible(): boolean { + return this._visible; + } + + set visible(visible: boolean) { + this._visible = visible; + this._onDidChangeVisibility.fire(visible); + } + + private _onDidChangeVisibility = new Emitter(); + get onDidChangeVisibility(): Event { return this._onDidChangeVisibility.event; } + + private _validateInput: InputValidator = () => Promise.resolve(undefined); + + get validateInput(): InputValidator { + return this._validateInput; + } + + set validateInput(validateInput: InputValidator) { + this._validateInput = validateInput; + this._onDidChangeValidateInput.fire(undefined); + } + + private _onDidChangeValidateInput = new Emitter(); + get onDidChangeValidateInput(): Event { return this._onDidChangeValidateInput.event; } +} diff --git a/packages/scm/src/common/index.ts b/packages/scm/src/common/index.ts new file mode 100644 index 0000000000000..be20242d05be1 --- /dev/null +++ b/packages/scm/src/common/index.ts @@ -0,0 +1,17 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. 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 + ********************************************************************************/ + +export * from './scm'; diff --git a/packages/scm/src/common/scm.ts b/packages/scm/src/common/scm.ts new file mode 100644 index 0000000000000..6d6942df8c4cf --- /dev/null +++ b/packages/scm/src/common/scm.ts @@ -0,0 +1,124 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. 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 URI from '@theia/core/lib/common/uri'; +import { Disposable, Event } from '@theia/core/lib/common'; +import { StatusBarEntry } from '@theia/core/lib/browser'; + +export interface ScmResourceDecorations { + icon?: URI; + tooltip?: string; + strikeThrough?: boolean; + faded?: boolean; + + source?: string; + letter?: string; +} + +export interface ScmResource { + readonly resourceGroup: ScmResourceGroup; + readonly sourceUri: URI; + readonly decorations?: ScmResourceDecorations; + + open(): Promise; +} + +export interface ScmResourceGroup { + readonly resources: ScmResource[]; + readonly provider: ScmProvider; + readonly label: string; + readonly id: string; + readonly hideWhenEmpty: boolean | undefined; + readonly onDidChange: Event; +} + +export interface ScmProvider extends Disposable { + readonly label: string; + readonly id: string; + readonly contextValue: string; + + readonly groups: ScmResourceGroup[]; + + readonly onDidChangeResources: Event; + + readonly rootUri?: string; + readonly count?: number; + readonly commitTemplate?: string; + readonly onDidChangeCommitTemplate?: Event; + readonly onDidChangeStatusBarCommands?: Event; + readonly acceptInputCommand?: StatusBarCommand; + readonly statusBarCommands?: StatusBarCommand[]; + readonly onDidChange: Event; + + getOriginalResource(uri: URI): Promise; +} + +export interface StatusBarCommand extends StatusBarEntry { + id: string +} + +export const enum InputValidationType { + Error = 0, + Warning = 1, + Information = 2 +} + +export interface InputValidation { + message: string; + type: InputValidationType; +} + +export interface InputValidator { + (value: string, cursorPosition: number): Promise; +} + +export interface ScmInput { + value: string; + readonly onDidChange: Event; + + placeholder: string; + readonly onDidChangePlaceholder: Event; + + validateInput: InputValidator; + readonly onDidChangeValidateInput: Event; + + visible: boolean; + readonly onDidChangeVisibility: Event; +} + +export interface ScmRepository extends Disposable { + readonly onDidFocus: Event; + readonly selected: boolean; + readonly onDidChangeSelection: Event; + readonly provider: ScmProvider; + readonly input: ScmInput; + + focus(): void; + + setSelected(selected: boolean): void; +} + +export const ScmService = Symbol('ScmService'); +export interface ScmService { + + readonly onDidAddRepository: Event; + readonly onDidRemoveRepository: Event; + + readonly repositories: ScmRepository[]; + readonly selectedRepositories: ScmRepository[]; + readonly onDidChangeSelectedRepositories: Event; + + registerScmProvider(provider: ScmProvider): ScmRepository; +} diff --git a/tsconfig.json b/tsconfig.json index c7899938a03a7..6865838e30431 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -106,6 +106,9 @@ "@theia/console/lib/*": [ "packages/console/src/*" ], + "@theia/console/lib/*": [ + "packages/scm/src/*" + ], "@theia/getting-started/lib/*": [ "packages/getting-started/src/*" ]