From 7f275428ff934300dc88180e18b34133ad3f5f95 Mon Sep 17 00:00:00 2001 From: Igor Vinokur Date: Tue, 19 Feb 2019 12:30:44 +0200 Subject: [PATCH] Introduce SCM Plugin-Api Signed-off-by: Igor Vinokur --- .travis.yml | 1 + examples/browser/package.json | 1 + packages/git/package.json | 1 + .../git/src/browser/git-view-contribution.ts | 146 ++++++-- packages/plugin-ext/package.json | 1 + packages/plugin-ext/src/api/plugin-api.ts | 89 ++++- .../src/main/browser/main-context.ts | 4 + .../plugin-ext/src/main/browser/scm-main.ts | 338 ++++++++++++++++++ .../plugin-ext/src/plugin/plugin-context.ts | 17 + packages/plugin-ext/src/plugin/scm.ts | 298 +++++++++++++++ packages/plugin/src/theia.d.ts | 229 ++++++++++++ packages/scm/compile.tsconfig.json | 10 + packages/scm/package.json | 45 +++ packages/scm/src/browser/index.ts | 17 + packages/scm/src/browser/scm-contribution.ts | 54 +++ .../scm/src/browser/scm-frontend-module.ts | 27 ++ packages/scm/src/browser/scm-service.ts | 300 ++++++++++++++++ tsconfig.json | 3 + 18 files changed, 1554 insertions(+), 27 deletions(-) create mode 100644 packages/plugin-ext/src/main/browser/scm-main.ts create mode 100644 packages/plugin-ext/src/plugin/scm.ts create mode 100644 packages/scm/compile.tsconfig.json create mode 100644 packages/scm/package.json create mode 100644 packages/scm/src/browser/index.ts create mode 100644 packages/scm/src/browser/scm-contribution.ts create mode 100644 packages/scm/src/browser/scm-frontend-module.ts create mode 100644 packages/scm/src/browser/scm-service.ts 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 eebf9f793d6fd..78b5f941294f6 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -44,6 +44,7 @@ "@theia/preview": "^0.3.19", "@theia/process": "^0.3.19", "@theia/python": "^0.3.19", + "@theia/scm": "^0.3.19", "@theia/search-in-workspace": "^0.3.19", "@theia/task": "^0.3.19", "@theia/terminal": "^0.3.19", diff --git a/packages/git/package.json b/packages/git/package.json index e399d8f575f86..f6f942b315b53 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -8,6 +8,7 @@ "@theia/filesystem": "^0.3.19", "@theia/languages": "^0.3.19", "@theia/navigator": "^0.3.19", + "@theia/scm": "^0.3.19", "@theia/workspace": "^0.3.19", "@types/diff": "^3.2.2", "@types/fs-extra": "^4.0.2", diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index 526cf3def706f..1751078d4fdf5 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, + DisposableCollection, + CommandRegistry, + MenuModelRegistry, + CommandContribution, + MenuContribution, + Command, + Emitter +} from '@theia/core'; +import { + AbstractViewContribution, StatusBar, DiffUris, StatusBarEntry, FrontendApplicationContribution, FrontendApplication, Widget } 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, ScmCommand } from '@theia/scm/lib/browser'; +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'; + private 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,26 @@ 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}`, + command: GIT_COMMANDS.CHANGE_REPOSITORY.id, + tooltip: path.toString() + }]); + } } else { this.statusBar.removeElement(GitViewContribution.GIT_SELECTED_REPOSITORY); } @@ -163,6 +190,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 +207,79 @@ 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}`, + 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) + ); + } + + /** Detect and handle added or removed repositories. */ + private 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; + } + + private 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 +504,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', + text: entry.text, + tooltip: entry.tooltip, + command: entry.command, + }]); + } } 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 236e5cad28706..81baa43244bb1 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'; @@ -61,6 +62,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 { ScmCommand } from '@theia/scm/lib/browser'; export interface PluginInitData { plugins: PluginMetadata[]; @@ -448,6 +450,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?: ScmCommand; + statusBarCommands?: ScmCommand[]; +} + +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; @@ -1012,7 +1095,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 = { @@ -1034,7 +1118,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..071d0205eaee9 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/scm-main.ts @@ -0,0 +1,338 @@ +/******************************************************************************** + * Copyright (C) 2019 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, + ScmCommand +} from '@theia/scm/lib/browser'; +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(): ScmCommand | undefined { + return this.features.acceptInputCommand; + } + + get statusBarCommands(): ScmCommand[] | 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 d049691820708..9f6abb5e7547a 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -120,6 +120,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, @@ -149,6 +150,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 { @@ -628,6 +630,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, @@ -639,6 +655,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..80a94e2c43e9b --- /dev/null +++ b/packages/plugin-ext/src/plugin/scm.ts @@ -0,0 +1,298 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { 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 = { + id: acceptInputCommand.id, + text: acceptInputCommand.label ? acceptInputCommand.label : '' + }; + 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 2f74df8512b33..89b2956d40ea7 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -6374,6 +6374,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..a23513b5e6b13 --- /dev/null +++ b/packages/scm/compile.tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ] +} diff --git a/packages/scm/package.json b/packages/scm/package.json new file mode 100644 index 0000000000000..5758ec82cbfb2 --- /dev/null +++ b/packages/scm/package.json @@ -0,0 +1,45 @@ +{ + "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", + "docs": "theiaext docs" + }, + "devDependencies": { + "@theia/ext-scripts": "^0.3.19" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/scm/src/browser/index.ts b/packages/scm/src/browser/index.ts new file mode 100644 index 0000000000000..68ff8632ca7c1 --- /dev/null +++ b/packages/scm/src/browser/index.ts @@ -0,0 +1,17 @@ +/******************************************************************************** + * Copyright (C) 2019 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-service'; diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts new file mode 100644 index 0000000000000..b9ffe51f32983 --- /dev/null +++ b/packages/scm/src/browser/scm-contribution.ts @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { inject, injectable } from 'inversify'; +import { + FrontendApplication, + FrontendApplicationContribution, + StatusBar, + StatusBarAlignment, + StatusBarEntry +} from '@theia/core/lib/browser'; +import { ScmCommand, ScmService } from './scm-service'; + +@injectable() +export class ScmContribution implements FrontendApplicationContribution { + @inject(StatusBar) protected readonly statusBar: StatusBar; + @inject(ScmService) protected readonly scmService: ScmService; + onStart(): void { + const refresh = (commands: ScmCommand[]) => { + commands.forEach(command => { + const statusBaCommand: StatusBarEntry = { + text: command.text, + tooltip: command.tooltip, + command: command.command, + alignment: StatusBarAlignment.LEFT, + priority: 100 + }; + this.statusBar.setElement(command.id, statusBaCommand); + }); + }; + this.scmService.onDidAddRepository(repository => { + const onDidChangeStatusBarCommands = repository.provider.onDidChangeStatusBarCommands; + if (onDidChangeStatusBarCommands) { + onDidChangeStatusBarCommands(commands => refresh(commands)); + } + }); + } + + onStop(app: FrontendApplication): void { + this.scmService.dispose(); + } +} 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..7eae491e85243 --- /dev/null +++ b/packages/scm/src/browser/scm-frontend-module.ts @@ -0,0 +1,27 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { ContainerModule } from 'inversify'; +import { ScmContribution } from './scm-contribution'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { ScmService, ScmServiceImpl } from './scm-service'; + +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/scm-service.ts b/packages/scm/src/browser/scm-service.ts new file mode 100644 index 0000000000000..f997c7158839c --- /dev/null +++ b/packages/scm/src/browser/scm-service.ts @@ -0,0 +1,300 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { Disposable, DisposableCollection, Emitter, Event } from '@theia/core/lib/common'; +import { injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; + +export const ScmService = Symbol('ScmService'); +export interface ScmService extends Disposable { + + readonly onDidAddRepository: Event; + readonly onDidRemoveRepository: Event; + + readonly repositories: ScmRepository[]; + readonly selectedRepositories: ScmRepository[]; + readonly onDidChangeSelectedRepositories: Event; + + registerScmProvider(provider: ScmProvider): ScmRepository; +} + +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?: ScmCommand; + readonly statusBarCommands?: ScmCommand[]; + readonly onDidChange: Event; + + getOriginalResource(uri: URI): 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 ScmResource { + readonly resourceGroup: ScmResourceGroup; + readonly sourceUri: URI; + readonly decorations?: ScmResourceDecorations; + + open(): Promise; +} + +export interface ScmResourceDecorations { + icon?: URI; + tooltip?: string; + strikeThrough?: boolean; + faded?: boolean; + + source?: string; + letter?: string; +} + +export interface ScmCommand { + id: string; + text: string; + tooltip?: string; + command?: 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; +} + +@injectable() +export class ScmServiceImpl implements ScmService { + private providerIds = new Set(); + private _repositories: ScmRepository[] = []; + private _selectedRepositories: ScmRepository[] = []; + + private disposableCollection: DisposableCollection = new DisposableCollection(); + private onDidChangeSelectedRepositoriesEmitter = new Emitter(); + private onDidAddProviderEmitter = new Emitter(); + private onDidRemoveProviderEmitter = new Emitter(); + + readonly onDidChangeSelectedRepositories: Event = this.onDidChangeSelectedRepositoriesEmitter.event; + + constructor() { + this.disposableCollection.push(this.onDidChangeSelectedRepositoriesEmitter); + this.disposableCollection.push(this.onDidAddProviderEmitter); + this.disposableCollection.push(this.onDidRemoveProviderEmitter); + } + + 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); + + const disposable: Disposable = Disposable.create(() => { + 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); + } + + dispose(): void { + this.disposableCollection.dispose(); + } +} + +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/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/*" ]