From d16c3f170b6b0fdf6b679182f700b244b0b7f264 Mon Sep 17 00:00:00 2001 From: Igor Vinokur Date: Tue, 9 Feb 2021 17:57:02 +0200 Subject: [PATCH] Update SCM Plugin API from the latest vscode Signed-off-by: Igor Vinokur --- .../browser/git-commit-message-validator.ts | 7 +- packages/git/src/browser/git-contribution.ts | 28 +- packages/plugin-ext/src/common/arrays.ts | 65 ++ .../plugin-ext/src/common/plugin-api-rpc.ts | 53 +- .../plugin-ext/src/main/browser/scm-main.ts | 581 ++++++------ .../plugin-ext/src/plugin/plugin-context.ts | 6 +- packages/plugin-ext/src/plugin/scm.ts | 853 ++++++++++++++---- packages/plugin-ext/src/plugin/types-impl.ts | 21 + packages/plugin/src/theia-proposed.d.ts | 50 + packages/plugin/src/theia.d.ts | 20 + .../scm/src/browser/scm-commit-widget.tsx | 17 +- packages/scm/src/browser/scm-input.ts | 8 +- 12 files changed, 1216 insertions(+), 493 deletions(-) diff --git a/packages/git/src/browser/git-commit-message-validator.ts b/packages/git/src/browser/git-commit-message-validator.ts index 1a9b3e2cb8c49..d64e4046a1cf8 100644 --- a/packages/git/src/browser/git-commit-message-validator.ts +++ b/packages/git/src/browser/git-commit-message-validator.ts @@ -16,6 +16,7 @@ import { injectable } from 'inversify'; import { MaybePromise } from '@theia/core/lib/common/types'; +import { ScmInputIssueType } from '@theia/scm/lib/browser/scm-input'; @injectable() export class GitCommitMessageValidator { @@ -42,14 +43,14 @@ export class GitCommitMessageValidator { protected isLineValid(line: string, index: number): GitCommitMessageValidator.Result | undefined { if (index === 1 && line.length !== 0) { return { - status: 'warning', + status: ScmInputIssueType.Warning, message: 'The second line should be empty to separate the commit message from the body' }; } const diff = line.length - this.maxCharsPerLine(); if (diff > 0) { return { - status: 'warning', + status: ScmInputIssueType.Warning, message: `${diff} characters over ${this.maxCharsPerLine()} in current line` }; } @@ -67,7 +68,7 @@ export namespace GitCommitMessageValidator { /** * Type for the validation result with a status and a corresponding message. */ - export type Result = Readonly<{ message: string, status: 'info' | 'success' | 'warning' | 'error' }>; + export type Result = Readonly<{ message: string, status: ScmInputIssueType }>; export namespace Result { diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts index 34c9955a178d8..09ac087eb443f 100644 --- a/packages/git/src/browser/git-contribution.ts +++ b/packages/git/src/browser/git-contribution.ts @@ -15,9 +15,22 @@ ********************************************************************************/ import { inject, injectable } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { Command, CommandContribution, CommandRegistry, DisposableCollection, MenuContribution, MenuModelRegistry, Mutable, MenuAction } from '@theia/core'; +import { + Command, + CommandContribution, + CommandRegistry, + DisposableCollection, + MenuAction, + MenuContribution, + MenuModelRegistry, + Mutable +} from '@theia/core'; import { DiffUris, Widget } from '@theia/core/lib/browser'; -import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { + TabBarToolbarContribution, + TabBarToolbarItem, + TabBarToolbarRegistry +} from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { EditorContextMenu, EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser'; import { Git, GitFileChange, GitFileStatus } from '../common'; import { GitRepositoryTracker } from './git-repository-tracker'; @@ -28,11 +41,12 @@ import { GitRepositoryProvider } from './git-repository-provider'; import { GitErrorHandler } from '../browser/git-error-handler'; import { ScmWidget } from '@theia/scm/lib/browser/scm-widget'; import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; -import { ScmResource, ScmCommand } from '@theia/scm/lib/browser/scm-provider'; +import { ScmCommand, ScmResource } from '@theia/scm/lib/browser/scm-provider'; import { ProgressService } from '@theia/core/lib/common/progress-service'; import { GitPreferences } from './git-preferences'; import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; +import { ScmInputIssueType } from '@theia/scm/lib/browser/scm-input'; export namespace GIT_COMMANDS { export const CLONE = { @@ -487,7 +501,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T const lastCommit = await scmRepository.provider.amendSupport.getLastCommit(); if (lastCommit === undefined) { scmRepository.input.issue = { - type: 'error', + type: ScmInputIssueType.Error, message: 'No previous commit to amend' }; scmRepository.input.focus(); @@ -727,7 +741,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T const message = options.message || scmRepository.input.value; if (!message.trim()) { scmRepository.input.issue = { - type: 'error', + type: ScmInputIssueType.Error, message: 'Please provide a commit message' }; scmRepository.input.focus(); @@ -735,7 +749,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T } if (!scmRepository.provider.stagedChanges.length) { scmRepository.input.issue = { - type: 'error', + type: ScmInputIssueType.Error, message: 'No changes added to commit' }; scmRepository.input.focus(); @@ -776,7 +790,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T scmRepository.input.focus(); } catch (e) { scmRepository.input.issue = { - type: 'warning', + type: ScmInputIssueType.Warning, message: 'Make sure you configure your \'user.name\' and \'user.email\' in git.' }; } diff --git a/packages/plugin-ext/src/common/arrays.ts b/packages/plugin-ext/src/common/arrays.ts index 01f77bd1c4698..46eeb9f95ee8b 100644 --- a/packages/plugin-ext/src/common/arrays.ts +++ b/packages/plugin-ext/src/common/arrays.ts @@ -38,3 +38,68 @@ export function isNonEmptyArray(obj: T[] | readonly T[] | undefined | null): export function flatten(arr: T[][]): T[] { return ([]).concat(...arr); } + +/** + * Diffs two *sorted* arrays and computes the splices which apply the diff. + */ +export function sortedDiff(before: ReadonlyArray, after: ReadonlyArray, compare: (a: T, b: T) => number): Splice[] { + const result: MutableSplice[] = []; + + function pushSplice(start: number, deleteCount: number, toInsert: T[]): void { + if (deleteCount === 0 && toInsert.length === 0) { + return; + } + + const latest = result[result.length - 1]; + + if (latest && latest.start + latest.deleteCount === start) { + latest.deleteCount += deleteCount; + latest.toInsert.push(...toInsert); + } else { + result.push({ start, deleteCount, toInsert }); + } + } + + let beforeIdx = 0; + let afterIdx = 0; + + while (true) { + if (beforeIdx === before.length) { + pushSplice(beforeIdx, 0, after.slice(afterIdx)); + break; + } + if (afterIdx === after.length) { + pushSplice(beforeIdx, before.length - beforeIdx, []); + break; + } + + const beforeElement = before[beforeIdx]; + const afterElement = after[afterIdx]; + const n = compare(beforeElement, afterElement); + if (n === 0) { + // equal + beforeIdx += 1; + afterIdx += 1; + } else if (n < 0) { + // beforeElement is smaller -> before element removed + pushSplice(beforeIdx, 1, []); + beforeIdx += 1; + } else if (n > 0) { + // beforeElement is greater -> after element added + pushSplice(beforeIdx, 0, [afterElement]); + afterIdx += 1; + } + } + + return result; +} + +interface MutableSplice extends Splice { + deleteCount: number; +} + +export interface Splice { + readonly start: number; + readonly deleteCount: number; + readonly toInsert: T[]; +} diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index d21232b797451..1a2d708721155 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -688,10 +688,11 @@ export namespace ScmCommandArg { export interface ScmExt { createSourceControl(plugin: Plugin, id: string, label: string, rootUri?: theia.Uri): theia.SourceControl; getLastInputBox(plugin: Plugin): theia.SourceControlInputBox | undefined; - $updateInputBox(sourceControlHandle: number, message: string): Promise; + $onInputBoxValueChange(sourceControlHandle: number, message: string): Promise; $executeResourceCommand(sourceControlHandle: number, groupHandle: number, resourceHandle: number): Promise; - $provideOriginalResource(sourceControlHandle: number, uri: string, token: CancellationToken): Promise; - $setSourceControlSelection(sourceControlHandle: number, selected: boolean): Promise; + $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string, number] | undefined>; + $setSelectedSourceControl(selectedSourceControlHandle: number | undefined): Promise; + $provideOriginalResource(sourceControlHandle: number, uri: string, token: theia.CancellationToken): Promise; } export namespace TimelineCommandArg { @@ -760,18 +761,19 @@ export interface DecorationData { } export interface ScmMain { - $registerSourceControl(sourceControlHandle: number, id: string, label: string, rootUri?: string): Promise + $registerSourceControl(sourceControlHandle: number, id: string, label: string, rootUri?: UriComponents): 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; + $registerGroups(sourceControlHandle: number, groups: ScmRawResourceGroup[], splices: ScmRawResourceSplices[]): void; + $updateGroup(sourceControlHandle: number, groupHandle: number, features: SourceControlGroupFeatures): void; + $updateGroupLabel(sourceControlHandle: number, groupHandle: number, label: string): void; + $unregisterGroup(sourceControlHandle: number, groupHandle: number): void; - $setInputBoxValue(sourceControlHandle: number, value: string): Promise; - $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): Promise; + $spliceResourceStates(sourceControlHandle: number, splices: ScmRawResourceSplices[]): void; + + $setInputBoxValue(sourceControlHandle: number, value: string): void; + $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): void; } export interface SourceControlProviderFeatures { @@ -786,6 +788,35 @@ export interface SourceControlGroupFeatures { hideWhenEmpty: boolean | undefined; } +export interface ScmRawResource { + handle: number, + sourceUri: UriComponents, + icons: UriComponents[], + tooltip: string, + strikeThrough: boolean, + faded: boolean, + contextValue: string, + command: Command | undefined +} + +export interface ScmRawResourceGroup { + handle: number, + id: string, + label: string, + features: SourceControlGroupFeatures +} + +export interface ScmRawResourceSplice { + start: number, + deleteCount: number, + rawResources: ScmRawResource[] +} + +export interface ScmRawResourceSplices { + handle: number, + splices: ScmRawResourceSplice[] +} + export interface SourceControlResourceState { readonly handle: number /** diff --git a/packages/plugin-ext/src/main/browser/scm-main.ts b/packages/plugin-ext/src/main/browser/scm-main.ts index 756fe4f2084d8..c36cbc75516c8 100644 --- a/packages/plugin-ext/src/main/browser/scm-main.ts +++ b/packages/plugin-ext/src/main/browser/scm-main.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2019 Red Hat, Inc. and others. + * Copyright (C) 2019-2021 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 @@ -14,13 +14,19 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// code copied and modified from https://github.com/microsoft/vscode/blob/1.52.1/src/vs/workbench/api/browser/mainThreadSCM.ts + import { MAIN_RPC_CONTEXT, ScmExt, SourceControlGroupFeatures, ScmMain, SourceControlProviderFeatures, - SourceControlResourceState + ScmRawResourceSplices, ScmRawResourceGroup } from '../../common/plugin-api-rpc'; import { ScmProvider, ScmResource, ScmResourceDecorations, ScmResourceGroup, ScmCommand } from '@theia/scm/lib/browser/scm-provider'; import { ScmRepository } from '@theia/scm/lib/browser/scm-repository'; @@ -28,354 +34,393 @@ import { ScmService } from '@theia/scm/lib/browser/scm-service'; import { RPCProtocol } from '../../common/rpc-protocol'; import { interfaces } from 'inversify'; import { Emitter, Event } from '@theia/core/lib/common/event'; -import { CancellationToken } from '@theia/core/lib/common/cancellation'; -import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; import URI from '@theia/core/lib/common/uri'; +import { URI as vscodeURI } from 'vscode-uri'; +import { Splice } from '../../common/arrays'; +import { UriComponents } from '../../common/uri-components'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; -export class ScmMainImpl implements ScmMain, Disposable { - private readonly proxy: ScmExt; - private readonly scmService: ScmService; - private readonly scmRepositoryMap = new Map(); - private readonly colors: ColorRegistry; - private lastSelectedSourceControlHandle: number | undefined; +export class PluginScmResourceGroup implements ScmResourceGroup { - private readonly toDispose = new DisposableCollection(); + readonly resources: ScmResource[] = []; - constructor(rpc: RPCProtocol, container: interfaces.Container) { - this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.SCM_EXT); - this.scmService = container.get(ScmService); - this.colors = container.get(ColorRegistry); - this.toDispose.push(this.scmService.onDidChangeSelectedRepository(repository => this.updateSelectedRepository(repository))); - } + private readonly onDidSpliceEmitter = new Emitter>(); + readonly onDidSplice = this.onDidSpliceEmitter.event; - dispose(): void { - this.toDispose.dispose(); - } + get hideWhenEmpty(): boolean { return !!this.features.hideWhenEmpty; } - protected updateSelectedRepository(repository: ScmRepository | undefined): void { - const sourceControlHandle = repository ? this.getSourceControlHandle(repository) : undefined; - if (sourceControlHandle !== undefined) { - this.proxy.$setSourceControlSelection(sourceControlHandle, true); - } - if (this.lastSelectedSourceControlHandle !== undefined && this.lastSelectedSourceControlHandle !== sourceControlHandle) { - this.proxy.$setSourceControlSelection(this.lastSelectedSourceControlHandle, false); - } - this.lastSelectedSourceControlHandle = sourceControlHandle; - } + private readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange: Event = this.onDidChangeEmitter.event; - protected getSourceControlHandle(repository: ScmRepository): number | undefined { - return Array.from(this.scmRepositoryMap.keys()).find(key => { - const scmRepository = this.scmRepositoryMap.get(key); - return scmRepository !== undefined && scmRepository.provider.rootUri === repository.provider.rootUri; - }); - } + constructor( + readonly handle: number, + public provider: PluginScmProvider, + public features: SourceControlGroupFeatures, + public label: string, + public id: string + ) { } - async $registerSourceControl(sourceControlHandle: number, id: string, label: string, rootUri: string): Promise { - const provider = new PluginScmProvider(this.proxy, sourceControlHandle, id, label, rootUri, this.colors); - const repository = this.scmService.registerScmProvider(provider); - repository.input.onDidChange(() => - this.proxy.$updateInputBox(sourceControlHandle, repository.input.value) - ); - this.scmRepositoryMap.set(sourceControlHandle, repository); - if (this.scmService.repositories.length === 1) { - this.updateSelectedRepository(repository); - } - this.toDispose.push(Disposable.create(() => this.$unregisterSourceControl(sourceControlHandle))); + splice(start: number, deleteCount: number, toInsert: ScmResource[]): void { + this.resources.splice(start, deleteCount, ...toInsert); + this.onDidSpliceEmitter.fire({ start, deleteCount, toInsert }); } - async $updateSourceControl(sourceControlHandle: number, features: SourceControlProviderFeatures): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - const provider = repository.provider as PluginScmProvider; - provider.updateSourceControl(features); - } + updateGroup(features: SourceControlGroupFeatures): void { + this.features = { ...this.features, ...features }; + this.onDidChangeEmitter.fire(); } - async $unregisterSourceControl(sourceControlHandle: number): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - repository.dispose(); - this.scmRepositoryMap.delete(sourceControlHandle); - } + updateGroupLabel(label: string): void { + this.label = label; + this.onDidChangeEmitter.fire(); } - async $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - repository.input.placeholder = placeholder; - } - } + dispose(): void { } +} - async $setInputBoxValue(sourceControlHandle: number, value: string): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - repository.input.value = value; - } +export class PluginScmResource implements ScmResource { + + constructor( + private readonly proxy: ScmExt, + private readonly sourceControlHandle: number, + private readonly groupHandle: number, + readonly handle: number, + readonly sourceUri: URI, + readonly group: PluginScmResourceGroup, + readonly decorations: ScmResourceDecorations, + readonly contextValue: string | undefined, + readonly command: ScmCommand | undefined + ) { } + + open(): Promise { + return this.proxy.$executeResourceCommand(this.sourceControlHandle, this.groupHandle, this.handle); } +} - async $registerGroup(sourceControlHandle: number, groupHandle: number, id: string, label: string): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - const provider = repository.provider as PluginScmProvider; - provider.registerGroup(groupHandle, id, label); - } +export class PluginScmProvider implements ScmProvider { + + private _id = this.contextValue; + get id(): string { return this._id; } + + readonly groups: PluginScmResourceGroup[] = []; + private readonly groupsByHandle: { [handle: number]: PluginScmResourceGroup; } = Object.create(null); + + private readonly onDidChangeResourcesEmitter = new Emitter(); + readonly onDidChangeResources: Event = this.onDidChangeResourcesEmitter.event; + + private features: SourceControlProviderFeatures = {}; + + get handle(): number { return this._handle; } + get label(): string { return this._label; } + get rootUri(): string { return this._rootUri ? this._rootUri.toString() : ''; } + get contextValue(): string { return this._contextValue; } + + get commitTemplate(): string { return this.features.commitTemplate || ''; } + get acceptInputCommand(): ScmCommand | undefined { return this.features.acceptInputCommand; } + get statusBarCommands(): ScmCommand[] | undefined { + const commands = this.features.statusBarCommands; + return commands?.map(command => { + const scmCommand: ScmCommand = command; + scmCommand.command = command.id; + return scmCommand; + }); } + get count(): number | undefined { return this.features.count; } + + private readonly onDidChangeCommitTemplateEmitter = new Emitter(); + readonly onDidChangeCommitTemplate: Event = this.onDidChangeCommitTemplateEmitter.event; + + private readonly onDidChangeStatusBarCommandsEmitter = new Emitter(); + get onDidChangeStatusBarCommands(): Event { return this.onDidChangeStatusBarCommandsEmitter.event; } - async $unregisterGroup(sourceControlHandle: number, groupHandle: number): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - const provider = repository.provider as PluginScmProvider; - provider.unregisterGroup(groupHandle); + private readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor( + private readonly proxy: ScmExt, + private readonly colors: ColorRegistry, + private readonly _handle: number, + private readonly _contextValue: string, + private readonly _label: string, + private readonly _rootUri: vscodeURI | undefined + ) { } + + updateSourceControl(features: SourceControlProviderFeatures): void { + this.features = { ...this.features, ...features }; + this.onDidChangeEmitter.fire(); + + if (typeof features.commitTemplate !== 'undefined') { + this.onDidChangeCommitTemplateEmitter.fire(this.commitTemplate!); } - } - async $updateGroup(sourceControlHandle: number, groupHandle: number, features: SourceControlGroupFeatures): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - const provider = repository.provider as PluginScmProvider; - provider.updateGroup(groupHandle, features); + if (typeof features.statusBarCommands !== 'undefined') { + this.onDidChangeStatusBarCommandsEmitter.fire(this.statusBarCommands!); } } - async $updateGroupLabel(sourceControlHandle: number, groupHandle: number, label: string): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - const provider = repository.provider as PluginScmProvider; - provider.updateGroupLabel(groupHandle, label); - } + registerGroups(resourceGroups: ScmRawResourceGroup[]): void { + const groups = resourceGroups.map( resourceGroup => { + const { handle, id, label, features } = resourceGroup; + const group = new PluginScmResourceGroup( + handle, + this, + features, + label, + id + ); + + this.groupsByHandle[handle] = group; + return group; + }); + + this.groups.splice(this.groups.length, 0, ...groups); } - async $updateResourceState(sourceControlHandle: number, groupHandle: number, resources: SourceControlResourceState[]): Promise { - const repository = this.scmRepositoryMap.get(sourceControlHandle); - if (repository) { - const provider = repository.provider as PluginScmProvider; - provider.updateGroupResourceStates(sourceControlHandle, groupHandle, resources); + updateGroup(handle: number, features: SourceControlGroupFeatures): void { + const group = this.groupsByHandle[handle]; + + if (!group) { + return; } - } -} -export class PluginScmProvider implements ScmProvider { - private onDidChangeEmitter = 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 readonly proxy: ScmExt, - readonly handle: number, - readonly id: string, - readonly label: string, - readonly rootUri: string, - protected readonly colors: ColorRegistry - ) { - this.disposableCollection.push(this.onDidChangeEmitter); - this.disposableCollection.push(this.onDidChangeCommitTemplateEmitter); - this.disposableCollection.push(this.onDidChangeStatusBarCommandsEmitter); + group.updateGroup(features); } - protected fireDidChange(): void { - this.onDidChangeEmitter.fire(undefined); - } + updateGroupLabel(handle: number, label: string): void { + const group = this.groupsByHandle[handle]; - get groups(): ScmResourceGroup[] { - return Array.from(this.groupsMap.values()); - } + if (!group) { + return; + } - get commitTemplate(): string | undefined { - return this.features.commitTemplate; + group.updateGroupLabel(label); } - get acceptInputCommand(): ScmCommand | undefined { - const command = this.features.acceptInputCommand; - if (command) { - const scmCommand: ScmCommand = command; - scmCommand.command = command.id; - return command; + spliceGroupResourceStates(splices: ScmRawResourceSplices[]): void { + for (const splice of splices) { + const groupHandle = splice.handle; + const groupSlices = splice.splices; + const group = this.groupsByHandle[groupHandle]; + + if (!group) { + console.warn(`SCM group ${groupHandle} not found in provider ${this.label}`); + continue; + } + + // reverse the splices sequence in order to apply them correctly + groupSlices.reverse(); + + for (const groupSlice of groupSlices) { + const { start, deleteCount, rawResources } = groupSlice; + const resources = rawResources.map(rawResource => { + const { handle, sourceUri, icons, tooltip, strikeThrough, faded, contextValue, command } = rawResource; + const icon = icons[0]; + const iconDark = icons[1] || icon; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const colorVariable = (rawResource as any).colorId && this.colors.toCssVariableName((rawResource as any).colorId); + const decorations = { + icon: icon ? vscodeURI.revive(icon) : undefined, + iconDark: iconDark ? vscodeURI.revive(iconDark) : undefined, + tooltip, + strikeThrough, + // TODO remove the letter and colorId fields when the FileDecorationProvider is applied, see https://github.com/eclipse-theia/theia/pull/8911 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + letter: (rawResource as any).letter || '', + color: colorVariable && `var(${colorVariable})`, + faded + } as ScmResourceDecorations; + + return new PluginScmResource( + this.proxy, + this.handle, + groupHandle, + handle, + new URI(vscodeURI.revive(sourceUri)), + group, + decorations, + contextValue || undefined, + command + ); + }); + + group.splice(start, deleteCount, resources); + } } + + this.onDidChangeResourcesEmitter.fire(); } - get statusBarCommands(): ScmCommand[] | undefined { - const commands = this.features.statusBarCommands; - if (commands) { - return commands.map(command => { - const scmCommand: ScmCommand = command; - scmCommand.command = command.id; - return scmCommand; - }); + unregisterGroup(handle: number): void { + const group = this.groupsByHandle[handle]; + + if (!group) { + return; } - } - get count(): number | undefined { - return this.features.count; + delete this.groupsByHandle[handle]; + this.groups.splice(this.groups.indexOf(group), 1); } - get onDidChangeCommitTemplate(): Event { - return this.onDidChangeCommitTemplateEmitter.event; - } + dispose(): void { } +} - get onDidChangeStatusBarCommands(): Event { - return this.onDidChangeStatusBarCommandsEmitter.event; - } +export class ScmMainImpl implements ScmMain { - get onDidChange(): Event { - return this.onDidChangeEmitter.event; + private readonly proxy: ScmExt; + private readonly scmService: ScmService; + private repositories = new Map(); + private repositoryDisposables = new Map(); + private readonly disposables = new DisposableCollection(); + private readonly colors: ColorRegistry; + + constructor( rpc: RPCProtocol, container: interfaces.Container) { + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.SCM_EXT); + this.scmService = container.get(ScmService); + this.colors = container.get(ColorRegistry); } dispose(): void { - this.disposableCollection.dispose(); + this.repositories.forEach(r => r.dispose()); + this.repositories.clear(); + + this.repositoryDisposables.forEach(d => d.dispose()); + this.repositoryDisposables.clear(); + + this.disposables.dispose(); } - updateSourceControl(features: SourceControlProviderFeatures): void { - if (features.acceptInputCommand) { - this.features.acceptInputCommand = features.acceptInputCommand; - } - if (features.commitTemplate) { - this.features.commitTemplate = features.commitTemplate; - } - if (features.count) { - this.features.count = features.count; - } - if (features.hasQuickDiffProvider !== undefined) { - this.features.hasQuickDiffProvider = features.hasQuickDiffProvider; - } - if (features.statusBarCommands) { - this.features.statusBarCommands = features.statusBarCommands; - } - this.fireDidChange(); + async $registerSourceControl(handle: number, id: string, label: string, rootUri: UriComponents | undefined): Promise { + const provider = new PluginScmProvider(this.proxy, this.colors, handle, id, label, rootUri ? vscodeURI.revive(rootUri) : undefined); + const repository = this.scmService.registerScmProvider(provider, { + input: { + validator: async value => { + const result = await this.proxy.$validateInput(handle, value, value.length); + return result && { message: result[0], type: result[1] }; + } + } + } + ); + this.repositories.set(handle, repository); + + const disposables = new DisposableCollection( + this.scmService.onDidChangeSelectedRepository(r => { + if (r === repository) { + this.proxy.$setSelectedSourceControl(handle); + } + }), + repository.input.onDidChange(() => this.proxy.$onInputBoxValueChange(handle, repository.input.value)) + ); - if (features.commitTemplate) { - this.onDidChangeCommitTemplateEmitter.fire(features.commitTemplate); + if (this.scmService.selectedRepository === repository) { + setTimeout(() => this.proxy.$setSelectedSourceControl(handle), 0); } - if (features.statusBarCommands) { - this.onDidChangeStatusBarCommandsEmitter.fire(features.statusBarCommands); + + if (repository.input.value) { + setTimeout(() => this.proxy.$onInputBoxValueChange(handle, repository.input.value), 0); } + + this.repositoryDisposables.set(handle, disposables); } - 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); - } + async $updateSourceControl(handle: number, features: SourceControlProviderFeatures): Promise { + const repository = this.repositories.get(handle); + + if (!repository) { + return; } - } - registerGroup(groupHandle: number, id: string, label: string): void { - const group = new PluginScmResourceGroup( - groupHandle, - this, - { hideWhenEmpty: undefined }, - label, - id - ); - this.groupsMap.set(groupHandle, group); - this.fireDidChange(); + const provider = repository.provider as PluginScmProvider; + provider.updateSourceControl(features); } - unregisterGroup(groupHandle: number): void { - const group = this.groupsMap.get(groupHandle); - if (group) { - group.dispose(); - this.groupsMap.delete(groupHandle); - this.fireDidChange(); + async $unregisterSourceControl(handle: number): Promise { + const repository = this.repositories.get(handle); + + if (!repository) { + return; } + + this.repositoryDisposables.get(handle)!.dispose(); + this.repositoryDisposables.delete(handle); + + repository.dispose(); + this.repositories.delete(handle); } - updateGroup(groupHandle: number, features: SourceControlGroupFeatures): void { - const group = this.groupsMap.get(groupHandle); - if (group) { - group.updateGroup(features); - this.fireDidChange(); + $registerGroups(sourceControlHandle: number, groups: ScmRawResourceGroup[], splices: ScmRawResourceSplices[]): void { + const repository = this.repositories.get(sourceControlHandle); + + if (!repository) { + return; } + + const provider = repository.provider as PluginScmProvider; + provider.registerGroups(groups); + provider.spliceGroupResourceStates(splices); } - updateGroupLabel(groupHandle: number, label: string): void { - const group = this.groupsMap.get(groupHandle); - if (group) { - group.updateGroupLabel(label); - this.fireDidChange(); + $updateGroup(sourceControlHandle: number, groupHandle: number, features: SourceControlGroupFeatures): void { + const repository = this.repositories.get(sourceControlHandle); + + if (!repository) { + return; } + + const provider = repository.provider as PluginScmProvider; + provider.updateGroup(groupHandle, features); } - async updateGroupResourceStates(sourceControlHandle: number, groupHandle: number, resources: SourceControlResourceState[]): Promise { - const group = this.groupsMap.get(groupHandle); - if (group) { - group.updateResources(await Promise.all(resources.map(async resource => { - const resourceUri = new URI(resource.resourceUri); - let scmDecorations; - const decorations = resource.decorations; - if (decorations) { - const colorVariable = resource.colorId && this.colors.toCssVariableName(resource.colorId); - scmDecorations = { - tooltip: decorations.tooltip, - letter: resource.letter, - color: colorVariable && `var(${colorVariable})` - }; - } - return new PluginScmResource( - this.proxy, - resource.handle, - group, - resourceUri, - group, - scmDecorations); - }))); - this.fireDidChange(); + $updateGroupLabel(sourceControlHandle: number, groupHandle: number, label: string): void { + const repository = this.repositories.get(sourceControlHandle); + + if (!repository) { + return; } - } -} + const provider = repository.provider as PluginScmProvider; + provider.updateGroupLabel(groupHandle, label); + } -export class PluginScmResourceGroup implements ScmResourceGroup { + $spliceResourceStates(sourceControlHandle: number, splices: ScmRawResourceSplices[]): void { + const repository = this.repositories.get(sourceControlHandle); - private _resources: PluginScmResource[] = []; + if (!repository) { + return; + } - constructor( - readonly handle: number, - public provider: PluginScmProvider, - public features: SourceControlGroupFeatures, - public label: string, - readonly id: string - ) { + const provider = repository.provider as PluginScmProvider; + provider.spliceGroupResourceStates(splices); } - get resources(): PluginScmResource[] { - return this._resources; - } + $unregisterGroup(sourceControlHandle: number, handle: number): void { + const repository = this.repositories.get(sourceControlHandle); - get hideWhenEmpty(): boolean | undefined { - return this.features.hideWhenEmpty; - } + if (!repository) { + return; + } - updateGroup(features: SourceControlGroupFeatures): void { - this.features = features; + const provider = repository.provider as PluginScmProvider; + provider.unregisterGroup(handle); } - updateGroupLabel(label: string): void { - this.label = label; - } + $setInputBoxValue(sourceControlHandle: number, value: string): void { + const repository = this.repositories.get(sourceControlHandle); - updateResources(resources: PluginScmResource[]): void { - this._resources = resources; - } + if (!repository) { + return; + } - dispose(): void { } + repository.input.value = value; + } -} + $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): void { + const repository = this.repositories.get(sourceControlHandle); -export class PluginScmResource implements ScmResource { - constructor( - private proxy: ScmExt, - readonly handle: number, - readonly group: PluginScmResourceGroup, - public sourceUri: URI, - public resourceGroup: ScmResourceGroup, - public decorations?: ScmResourceDecorations - ) { } + if (!repository) { + return; + } - open(): Promise { - return this.proxy.$executeResourceCommand(this.group.provider.handle, this.group.handle, this.handle); + repository.input.placeholder = placeholder; } } diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index d5a4058acea6a..e3f51a94cd32c 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -129,7 +129,8 @@ import { SemanticTokens, SemanticTokensEdits, SemanticTokensEdit, - ColorThemeKind + ColorThemeKind, + SourceControlInputBoxValidationType } from './types-impl'; import { AuthenticationExtImpl } from './authentication-ext'; import { SymbolKind } from '../common/plugin-api-rpc-model'; @@ -930,7 +931,8 @@ export function createAPIFactory( SemanticTokens, SemanticTokensEdits, SemanticTokensEdit, - ColorThemeKind + ColorThemeKind, + SourceControlInputBoxValidationType }; }; } diff --git a/packages/plugin-ext/src/plugin/scm.ts b/packages/plugin-ext/src/plugin/scm.ts index 0e33b3444d993..92bec48a6c18e 100644 --- a/packages/plugin-ext/src/plugin/scm.ts +++ b/packages/plugin-ext/src/plugin/scm.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2019 Red Hat, Inc. and others. + * Copyright (C) 2019-2021 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 @@ -14,160 +14,489 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// code copied and modified from https://github.com/microsoft/vscode/blob/1.52.1/src/vs/workbench/api/common/extHostSCM.ts + import * as theia from '@theia/plugin'; -import { Plugin as InternalPlugin, PLUGIN_RPC_CONTEXT, ScmExt, ScmMain, ScmCommandArg } from '../common/plugin-api-rpc'; -import { RPCProtocol } from '../common/rpc-protocol'; -import { CancellationToken } from '@theia/core/lib/common/cancellation'; -import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; +import { Emitter, Event } from '@theia/core/lib/common/event'; +import { + Plugin, PLUGIN_RPC_CONTEXT, + ScmExt, + ScmMain, ScmRawResource, ScmRawResourceGroup, + ScmRawResourceSplice, ScmRawResourceSplices, + SourceControlGroupFeatures +} from '../common'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { CommandRegistryImpl } from '../plugin/command-registry'; +import { sortedDiff, Splice } from '../common/arrays'; import { UriComponents } from '../common/uri-components'; -import URI from '@theia/core/lib/common/uri'; -import { CommandRegistryImpl } from './command-registry'; -import { Emitter } from '@theia/core/lib/common/event'; import { Command } from '../common/plugin-api-rpc-model'; +import { RPCProtocol } from '../common/rpc-protocol'; +import { URI } from 'vscode-uri'; +import { ScmCommandArg } from '../common/plugin-api-rpc'; +import { sep } from '@theia/callhierarchy/lib/common/paths'; +type ProviderHandle = number; +type GroupHandle = number; +type ResourceStateHandle = number; + +function getIconResource(decorations?: theia.SourceControlResourceThemableDecorations): theia.Uri | undefined { + if (!decorations) { + return undefined; + } else if (typeof decorations.iconPath === 'string') { + return URI.file(decorations.iconPath); + } else { + return decorations.iconPath; + } +} -export class ScmExtImpl implements ScmExt { - private handle: number = 0; - private readonly proxy: ScmMain; - private readonly sourceControlMap = new Map(); - private readonly sourceControlsByPluginMap: Map = new Map(); +function comparePaths(one: string, other: string, caseSensitive = false): number { + const oneParts = one.split(sep); + const otherParts = other.split(sep); + + const lastOne = oneParts.length - 1; + const lastOther = otherParts.length - 1; + let endOne: boolean; + let endOther: boolean; + + for (let i = 0; ; i++) { + endOne = lastOne === i; + endOther = lastOther === i; + + if (endOne && endOther) { + const onePart = caseSensitive ? oneParts[i].toLocaleLowerCase() : oneParts[i]; + const otherPart = caseSensitive ? otherParts[i].toLocaleLowerCase() : otherParts[i]; + return onePart > otherPart ? -1 : 1; + } else if (endOne) { + return -1; + } else if (endOther) { + return 1; + } - constructor(readonly rpc: RPCProtocol, private readonly commands: CommandRegistryImpl) { - this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.SCM_MAIN); - commands.registerArgumentProcessor({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - processArgument: (arg: any) => { - if (!ScmCommandArg.is(arg)) { - return arg; - } - const sourceControl = this.sourceControlMap.get(arg.sourceControlHandle); - if (!sourceControl) { - return undefined; - } - if (typeof arg.resourceGroupHandle !== 'number') { - return sourceControl; - } - const resourceGroup = sourceControl.getResourceGroup(arg.resourceGroupHandle); - if (typeof arg.resourceStateHandle !== 'number') { - return resourceGroup; - } - return resourceGroup && resourceGroup.getResourceState(arg.resourceStateHandle); - } - }); + if (endOne) { + return -1; + } else if (endOther) { + return 1; + } + + const result = comparePathComponents(oneParts[i], otherParts[i], caseSensitive); + + if (result !== 0) { + return result; + } } +} - 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; +function comparePathComponents(one: string, other: string, caseSensitive = false): number { + if (!caseSensitive) { + one = one && one.toLowerCase(); + other = other && other.toLowerCase(); } - 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; + if (one === other) { + return 0; } - 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); - } - } + return one < other ? -1 : 1; +} + +function compareResourceThemableDecorations(a: theia.SourceControlResourceThemableDecorations, b: theia.SourceControlResourceThemableDecorations): number { + if (!a.iconPath && !b.iconPath) { + return 0; + } else if (!a.iconPath) { + return -1; + } else if (!b.iconPath) { + return 1; } - 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) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newUri: any = new URI(uri); - newUri.fsPath = uri; - return sourceControl.quickDiffProvider.provideOriginalResource(newUri, token); + const aPath = typeof a.iconPath === 'string' ? a.iconPath : a.iconPath.fsPath; + const bPath = typeof b.iconPath === 'string' ? b.iconPath : b.iconPath.fsPath; + return comparePaths(aPath, bPath); +} + +function compareResourceStatesDecorations(a: theia.SourceControlResourceDecorations, b: theia.SourceControlResourceDecorations): number { + let result = 0; + + if (a.strikeThrough !== b.strikeThrough) { + return a.strikeThrough ? 1 : -1; + } + + if (a.faded !== b.faded) { + return a.faded ? 1 : -1; + } + + if (a.tooltip !== b.tooltip) { + return (a.tooltip || '').localeCompare(b.tooltip || ''); + } + + result = compareResourceThemableDecorations(a, b); + + if (result !== 0) { + return result; + } + + if (a.light && b.light) { + result = compareResourceThemableDecorations(a.light, b.light); + } else if (a.light) { + return 1; + } else if (b.light) { + return -1; + } + + if (result !== 0) { + return result; + } + + if (a.dark && b.dark) { + result = compareResourceThemableDecorations(a.dark, b.dark); + } else if (a.dark) { + return 1; + } else if (b.dark) { + return -1; + } + + return result; +} + +function compareCommands(a: theia.Command, b: theia.Command): number { + if (a.command !== b.command) { + return a.command! < b.command! ? -1 : 1; + } + + if (a.title !== b.title) { + return a.title! < b.title! ? -1 : 1; + } + + if (a.tooltip !== b.tooltip) { + if (a.tooltip !== undefined && b.tooltip !== undefined) { + return a.tooltip < b.tooltip ? -1 : 1; + } else if (a.tooltip !== undefined) { + return 1; + } else if (b.tooltip !== undefined) { + return -1; } } - async $updateInputBox(sourceControlHandle: number, value: string): Promise { - const sourceControl = this.sourceControlMap.get(sourceControlHandle); - if (sourceControl) { - sourceControl.inputBox.$updateValue(value); + if (a.arguments === b.arguments) { + return 0; + } else if (!a.arguments) { + return -1; + } else if (!b.arguments) { + return 1; + } else if (a.arguments.length !== b.arguments.length) { + return a.arguments.length - b.arguments.length; + } + + for (let i = 0; i < a.arguments.length; i++) { + const aArg = a.arguments[i]; + const bArg = b.arguments[i]; + + if (aArg === bArg) { + continue; } + + return aArg < bArg ? -1 : 1; } - async $setSourceControlSelection(sourceControlHandle: number, selected: boolean): Promise { - const sourceControl = this.sourceControlMap.get(sourceControlHandle); - if (sourceControl) { - sourceControl.selected = selected; + return 0; +} + +function compareResourceStates(a: theia.SourceControlResourceState, b: theia.SourceControlResourceState): number { + let result = comparePaths(a.resourceUri.fsPath, b.resourceUri.fsPath, true); + + if (result !== 0) { + return result; + } + + if (a.command && b.command) { + result = compareCommands(a.command, b.command); + } else if (a.command) { + return 1; + } else if (b.command) { + return -1; + } + + if (result !== 0) { + return result; + } + + if (a.decorations && b.decorations) { + result = compareResourceStatesDecorations(a.decorations, b.decorations); + } else if (a.decorations) { + return 1; + } else if (b.decorations) { + return -1; + } + + return result; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function compareArgs(a: any[], b: any[]): boolean { + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; } } + + return true; } -class InputBoxImpl implements theia.SourceControlInputBox { - private _placeholder: string; - private _value: string; +function commandEquals(a: theia.Command, b: theia.Command): boolean { + return a.command === b.command + && a.title === b.title + && a.tooltip === b.tooltip + && (a.arguments && b.arguments ? compareArgs(a.arguments, b.arguments) : a.arguments === b.arguments); +} - constructor(private proxy: ScmMain, private sourceControlHandle: number) { +function commandListEquals(a: readonly theia.Command[], b: readonly theia.Command[]): boolean { + return equals(a, b, commandEquals); +} + +function equals(one: ReadonlyArray | undefined, other: ReadonlyArray | undefined, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean { + if (one === other) { + return true; } + if (!one || !other) { + return false; + } + + if (one.length !== other.length) { + return false; + } + + for (let i = 0, len = one.length; i < len; i++) { + if (!itemEquals(one[i], other[i])) { + return false; + } + } + + return true; +} + +interface ValidateInput { + (value: string, cursorPosition: number): theia.ProviderResult; +} + +export class ScmInputBoxImpl implements theia.SourceControlInputBox { + + private _value: string = ''; + get value(): string { return this._value; } set value(value: string) { - this.$updateValue(value); this.proxy.$setInputBoxValue(this.sourceControlHandle, value); + this.updateValue(value); } - $updateValue(value: string): void { - this._value = value; + private readonly onDidChangeEmitter = new Emitter(); + + get onDidChange(): Event { + return this.onDidChangeEmitter.event; } + private _placeholder: string = ''; + get placeholder(): string { return this._placeholder; } set placeholder(placeholder: string) { - this._placeholder = placeholder; this.proxy.$setInputBoxPlaceholder(this.sourceControlHandle, placeholder); + this._placeholder = placeholder; + } + + private _validateInput: ValidateInput | undefined; + + get validateInput(): ValidateInput | undefined { + return this._validateInput; + } + + set validateInput(fn: ValidateInput | undefined) { + if (fn && typeof fn !== 'function') { + throw new Error(`[${this.plugin.model.id}]: Invalid SCM input box validation function`); + } + + this._validateInput = fn; + } + + constructor(private plugin: Plugin, private proxy: ScmMain, private sourceControlHandle: number) { + // noop + } + + onInputBoxValueChange(value: string): void { + this.updateValue(value); + } + + private updateValue(value: string): void { + this._value = value; + this.onDidChangeEmitter.fire(value); } } -class SourceControlImpl implements theia.SourceControl { - private static handle: number = 0; - private static resourceGroupHandle: number = 0; - private handle = SourceControlImpl.handle++; - - private readonly resourceGroupsMap = new Map(); - - private readonly _inputBox: InputBoxImpl; - private _count: number | undefined; - private _quickDiffProvider: theia.QuickDiffProvider | undefined; - private _commitTemplate: string | undefined; - private _acceptInputCommand: theia.Command | undefined; - private _statusBarCommands: theia.Command[] | undefined; - private _selected: boolean = false; +class SsmResourceGroupImpl implements theia.SourceControlResourceGroup { - private readonly toDispose = new DisposableCollection(); + private static handlePool: number = 0; + private resourceHandlePool: number = 0; + private _resourceStates: theia.SourceControlResourceState[] = []; - private readonly onDidChangeSelectionEmitter = new Emitter(); - readonly onDidChangeSelection: theia.Event = this.onDidChangeSelectionEmitter.event; + private resourceStatesMap = new Map(); + private resourceStatesCommandsMap = new Map(); + private resourceStatesDisposablesMap = new Map(); + + private readonly onDidUpdateResourceStatesEmitter = new Emitter(); + readonly onDidUpdateResourceStates = this.onDidUpdateResourceStatesEmitter.event; + + private _disposed = false; + get disposed(): boolean { return this._disposed; } + private readonly onDidDisposeEmitter = new Emitter(); + readonly onDidDispose = this.onDidDisposeEmitter.event; + + private handlesSnapshot: number[] = []; + private resourceSnapshot: theia.SourceControlResourceState[] = []; + + 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); + } + + private _hideWhenEmpty: boolean | undefined = undefined; + get hideWhenEmpty(): boolean | undefined { return this._hideWhenEmpty; } + set hideWhenEmpty(hideWhenEmpty: boolean | undefined) { + this._hideWhenEmpty = hideWhenEmpty; + this.proxy.$updateGroup(this.sourceControlHandle, this.handle, this.features); + } + + get features(): SourceControlGroupFeatures { + return { + hideWhenEmpty: this.hideWhenEmpty + }; + } + + get resourceStates(): theia.SourceControlResourceState[] { return [...this._resourceStates]; } + set resourceStates(resources: theia.SourceControlResourceState[]) { + this._resourceStates = [...resources]; + this.onDidUpdateResourceStatesEmitter.fire(); + } + + readonly handle = SsmResourceGroupImpl.handlePool++; constructor( private proxy: ScmMain, private commands: CommandRegistryImpl, + private sourceControlHandle: number, 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); - this.toDispose.push(Disposable.create(() => this.proxy.$unregisterSourceControl(this.handle))); + ) { } + + getResourceState(handle: number): theia.SourceControlResourceState | undefined { + return this.resourceStatesMap.get(handle); + } + + executeResourceCommand(handle: number): Promise { + const command = this.resourceStatesCommandsMap.get(handle); + + if (!command) { + return Promise.resolve(undefined); + } + + return new Promise(() => this.commands.executeCommand(command.command!, ...(command.arguments || []))); + } + + takeResourceStateSnapshot(): ScmRawResourceSplice[] { + const snapshot = [...this._resourceStates]; + const diffs = sortedDiff(this.resourceSnapshot, snapshot, compareResourceStates); + + const splices = diffs.map>(diff => { + const toInsert = diff.toInsert.map(r => { + const handle = this.resourceHandlePool++; + this.resourceStatesMap.set(handle, r); + + const sourceUri = r.resourceUri; + const iconUri = getIconResource(r.decorations); + const lightIconUri = r.decorations && getIconResource(r.decorations.light) || iconUri; + const darkIconUri = r.decorations && getIconResource(r.decorations.dark) || iconUri; + const icons: UriComponents[] = []; + let command: Command | undefined; + + if (r.command) { + if (r.command.command === 'theia.open' || r.command.command === 'theia.diff') { + const disposables = new DisposableCollection(); + command = this.commands.converter.toSafeCommand(r.command, disposables); + this.resourceStatesDisposablesMap.set(handle, disposables); + } else { + this.resourceStatesCommandsMap.set(handle, r.command); + } + } + + if (lightIconUri) { + icons.push(lightIconUri); + } + + if (darkIconUri && (darkIconUri.toString() !== lightIconUri?.toString())) { + icons.push(darkIconUri); + } + + const tooltip = (r.decorations && r.decorations.tooltip) || ''; + const strikeThrough = r.decorations && !!r.decorations.strikeThrough; + const faded = r.decorations && !!r.decorations.faded; + const contextValue = r.contextValue || ''; + + // TODO remove the letter and colorId fields when the FileDecorationProvider is applied, see https://github.com/eclipse-theia/theia/pull/8911 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawResource = { handle, sourceUri, letter: (r as any).letter, colorId: (r as any).color.id, icons, + tooltip, strikeThrough, faded, contextValue, command } as ScmRawResource; + + return { rawResource, handle }; + }); + + const { start, deleteCount } = diff; + return { start, deleteCount, toInsert }; + }); + + const rawResourceSplices = splices + .map(({ start, deleteCount, toInsert }) => ({ + start: start, + deleteCount: deleteCount, + rawResources: toInsert.map(i => i.rawResource) + } as ScmRawResourceSplice)); + + const reverseSplices = splices.reverse(); + + for (const { start, deleteCount, toInsert } of reverseSplices) { + const handles = toInsert.map(i => i.handle); + const handlesToDelete = this.handlesSnapshot.splice(start, deleteCount, ...handles); + + for (const handle of handlesToDelete) { + this.resourceStatesMap.delete(handle); + this.resourceStatesCommandsMap.delete(handle); + this.resourceStatesDisposablesMap.get(handle)?.dispose(); + this.resourceStatesDisposablesMap.delete(handle); + } + } + + this.resourceSnapshot = snapshot; + return rawResourceSplices; + } + + dispose(): void { + this._disposed = true; + this.onDidDisposeEmitter.fire(); } +} + +class SourceControlImpl implements theia.SourceControl { + + private static handlePool: number = 0; + private groups: Map = new Map(); get id(): string { return this._id; @@ -181,28 +510,26 @@ class SourceControlImpl implements theia.SourceControl { 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(SourceControlImpl.resourceGroupHandle++, sourceControlResourceGroup); - this.toDispose.push(sourceControlResourceGroup); - return sourceControlResourceGroup; - } + private _inputBox: ScmInputBoxImpl; + get inputBox(): ScmInputBoxImpl { return this._inputBox; } - get inputBox(): InputBoxImpl { - return this._inputBox; - } + private _count: number | undefined = undefined; 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 }); + if (this._count === count) { + return; } + + this._count = count; + this.proxy.$updateSourceControl(this.handle, { count }); } + private _quickDiffProvider: theia.QuickDiffProvider | undefined = undefined; + get quickDiffProvider(): theia.QuickDiffProvider | undefined { return this._quickDiffProvider; } @@ -212,158 +539,286 @@ class SourceControlImpl implements theia.SourceControl { this.proxy.$updateSourceControl(this.handle, { hasQuickDiffProvider: !!quickDiffProvider }); } + private _commitTemplate: string | undefined = undefined; + get commitTemplate(): string | undefined { return this._commitTemplate; } set commitTemplate(commitTemplate: string | undefined) { + if (commitTemplate === this._commitTemplate) { + return; + } + this._commitTemplate = commitTemplate; this.proxy.$updateSourceControl(this.handle, { commitTemplate }); } - dispose(): void { - this.toDispose.dispose(); - } - - protected toDisposeOnAcceptInputCommand = new DisposableCollection(); + private acceptInputDisposables = new DisposableCollection(); + private _acceptInputCommand: theia.Command | undefined = undefined; get acceptInputCommand(): theia.Command | undefined { return this._acceptInputCommand; } set acceptInputCommand(acceptInputCommand: theia.Command | undefined) { - this.toDisposeOnAcceptInputCommand.dispose(); - this.toDispose.push(this.toDisposeOnAcceptInputCommand); + this.acceptInputDisposables = new DisposableCollection(); this._acceptInputCommand = acceptInputCommand; - this.proxy.$updateSourceControl(this.handle, { - acceptInputCommand: this.commands.converter.toSafeCommand(acceptInputCommand, this.toDisposeOnAcceptInputCommand) - }); + const internal = this.commands.converter.toSafeCommand(acceptInputCommand, this.acceptInputDisposables); + this.proxy.$updateSourceControl(this.handle, { acceptInputCommand: internal }); } - protected toDisposeOnStatusBarCommands = new DisposableCollection(); + private _statusBarDisposables = new DisposableCollection(); + private _statusBarCommands: theia.Command[] | undefined = undefined; get statusBarCommands(): theia.Command[] | undefined { return this._statusBarCommands; } set statusBarCommands(statusBarCommands: theia.Command[] | undefined) { - this.toDisposeOnStatusBarCommands.dispose(); - this.toDispose.push(this.toDisposeOnStatusBarCommands); + if (this._statusBarCommands && statusBarCommands && commandListEquals(this._statusBarCommands, statusBarCommands)) { + return; + } + + this._statusBarDisposables = new DisposableCollection(); this._statusBarCommands = statusBarCommands; - let safeStatusBarCommands: Command[] | undefined; - if (statusBarCommands) { - safeStatusBarCommands = statusBarCommands.map(statusBarCommand => this.commands.converter.toSafeCommand(statusBarCommand, this.toDisposeOnStatusBarCommands)); - } - this.proxy.$updateSourceControl(this.handle, { - statusBarCommands: safeStatusBarCommands - }); + const internal = (statusBarCommands || []).map(c => this.commands.converter.toSafeCommand(c, this._statusBarDisposables)) as Command[]; + this.proxy.$updateSourceControl(this.handle, { statusBarCommands: internal }); } - getResourceGroup(handle: number): SourceControlResourceGroupImpl | undefined { - return this.resourceGroupsMap.get(handle); - } + private _selected: boolean = false; get selected(): boolean { return this._selected; } - set selected(selected: boolean) { - this._selected = selected; - this.onDidChangeSelectionEmitter.fire(selected); - } -} + private readonly onDidChangeSelectionEmitter = new Emitter(); + readonly onDidChangeSelection = this.onDidChangeSelectionEmitter.event; -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(); + private handle: number = SourceControlImpl.handlePool++; constructor( + plugin: Plugin, private proxy: ScmMain, private commands: CommandRegistryImpl, - private sourceControlHandle: number, private _id: string, private _label: string, + private _rootUri?: theia.Uri ) { - this.proxy.$registerGroup(sourceControlHandle, this.handle, _id, _label); + this._inputBox = new ScmInputBoxImpl(plugin, this.proxy, this.handle); + this.proxy.$registerSourceControl(this.handle, _id, _label, _rootUri); } - get id(): string { - return this._id; + private createdResourceGroups = new Map(); + private updatedResourceGroups = new Set(); + + createResourceGroup(id: string, label: string): SsmResourceGroupImpl { + const group = new SsmResourceGroupImpl(this.proxy, this.commands, this.handle, id, label); + const disposable = group.onDidDispose(() => this.createdResourceGroups.delete(group)); + this.createdResourceGroups.set(group, disposable); + this.eventuallyAddResourceGroups(); + return group; } - get label(): string { - return this._label; + eventuallyAddResourceGroups(): void { + const groups: ScmRawResourceGroup[] = []; + const splices: ScmRawResourceSplices[] = []; + + for (const [group, disposable] of this.createdResourceGroups) { + disposable.dispose(); + + const updateListener = group.onDidUpdateResourceStates(() => { + this.updatedResourceGroups.add(group); + this.eventuallyUpdateResourceStates(); + }); + + group.onDidDispose(() => { + this.updatedResourceGroups.delete(group); + updateListener.dispose(); + this.groups.delete(group.handle); + this.proxy.$unregisterGroup(this.handle, group.handle); + }); + + const { handle , id, label, features } = group; + groups.push({ handle , id, label, features }); + + const snapshot = group.takeResourceStateSnapshot(); + + if (snapshot.length > 0) { + splices.push( { handle: group.handle, splices: snapshot }); + } + + this.groups.set(group.handle, group); + } + + this.proxy.$registerGroups(this.handle, groups, splices); + this.createdResourceGroups.clear(); } - set label(label: string) { - this._label = label; - this.proxy.$updateGroupLabel(this.sourceControlHandle, this.handle, label); + eventuallyUpdateResourceStates(): void { + const splices: ScmRawResourceSplices[] = []; + + this.updatedResourceGroups.forEach(group => { + const snapshot = group.takeResourceStateSnapshot(); + + if (snapshot.length === 0) { + return; + } + + splices.push({ handle: group.handle, splices: snapshot }); + }); + + if (splices.length > 0) { + this.proxy.$spliceResourceStates(this.handle, splices); + } + + this.updatedResourceGroups.clear(); } - get hideWhenEmpty(): boolean | undefined { - return this._hideWhenEmpty; + getResourceGroup(handle: GroupHandle): SsmResourceGroupImpl | undefined { + return this.groups.get(handle); } - set hideWhenEmpty(hideWhenEmpty: boolean | undefined) { - this._hideWhenEmpty = hideWhenEmpty; - this.proxy.$updateGroup(this.sourceControlHandle, this.handle, { hideWhenEmpty }); + setSelectionState(selected: boolean): void { + this._selected = selected; + this.onDidChangeSelectionEmitter.fire(selected); } - get resourceStates(): theia.SourceControlResourceState[] { - return this._resourceStates; + dispose(): void { + this.acceptInputDisposables.dispose(); + this._statusBarDisposables.dispose(); + + this.groups.forEach(group => group.dispose()); + this.proxy.$unregisterSourceControl(this.handle); } +} - 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 resourceCommand; - let decorations; - if (resourceState.command) { - const { command, title, tooltip } = resourceState.command; - resourceCommand = { id: command ? command : '', title: title ? title : '', 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; +export class ScmExtImpl implements ScmExt { + + private static handlePool: number = 0; + + private proxy: ScmMain; + private sourceControls: Map = new Map(); + private sourceControlsByExtension: Map = new Map(); + + private readonly onDidChangeActiveProviderEmitter = new Emitter(); + get onDidChangeActiveProvider(): Event { return this.onDidChangeActiveProviderEmitter.event; } + + private selectedSourceControlHandle: number | undefined; + + constructor( rpc: RPCProtocol, private commands: CommandRegistryImpl) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.SCM_MAIN); + + commands.registerArgumentProcessor({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + processArgument: (arg: any) => { + if (!ScmCommandArg.is(arg)) { + return arg; } - decorations = { strikeThrough, faded, tooltip, iconPath }; + const sourceControl = this.sourceControls.get(arg.sourceControlHandle); + if (!sourceControl) { + return undefined; + } + if (typeof arg.resourceGroupHandle !== 'number') { + return sourceControl; + } + const resourceGroup = sourceControl.getResourceGroup(arg.resourceGroupHandle); + if (typeof arg.resourceStateHandle !== 'number') { + return resourceGroup; + } + return resourceGroup && resourceGroup.getResourceState(arg.resourceStateHandle); } - this.resourceStatesMap.set(handle, resourceState); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resource: any = resourceState; - return { handle, resourceUri: resourceState.resourceUri.path, command: resourceCommand, decorations, letter: resource.letter, colorId: resource.color.id }; - })); + }); } - async executeResourceCommand(stateHandle: number): Promise { - const state = this.resourceStatesMap.get(stateHandle); - if (state && state.command) { - const command = state.command; - if (command.command) { - await this.commands.$executeCommand(command.command, ...command.arguments); - } + createSourceControl(extension: Plugin, id: string, label: string, rootUri: theia.Uri | undefined): theia.SourceControl { + const handle = ScmExtImpl.handlePool++; + const sourceControl = new SourceControlImpl(extension, this.proxy, this.commands, id, label, rootUri); + this.sourceControls.set(handle, sourceControl); + + const sourceControls = this.sourceControlsByExtension.get(extension.model.id) || []; + sourceControls.push(sourceControl); + this.sourceControlsByExtension.set(extension.model.id, sourceControls); + + return sourceControl; + } + + getLastInputBox(extension: Plugin): ScmInputBoxImpl | undefined { + const sourceControls = this.sourceControlsByExtension.get(extension.model.id); + const sourceControl = sourceControls && sourceControls[sourceControls.length - 1]; + return sourceControl && sourceControl.inputBox; + } + + $provideOriginalResource(sourceControlHandle: number, uriComponents: string, token: theia.CancellationToken): Promise { + const sourceControl = this.sourceControls.get(sourceControlHandle); + + if (!sourceControl || !sourceControl.quickDiffProvider || !sourceControl.quickDiffProvider.provideOriginalResource) { + return Promise.resolve(undefined); } + + return new Promise(() => sourceControl.quickDiffProvider!.provideOriginalResource!(URI.file(uriComponents), token)) + .then(r => r || undefined); } - getResourceState(handle: number): theia.SourceControlResourceState | undefined { - return this.resourceStatesMap.get(handle); + $onInputBoxValueChange(sourceControlHandle: number, value: string): Promise { + const sourceControl = this.sourceControls.get(sourceControlHandle); + + if (!sourceControl) { + return Promise.resolve(undefined); + } + + sourceControl.inputBox.onInputBoxValueChange(value); + return Promise.resolve(undefined); } - dispose(): void { - this.proxy.$unregisterGroup(this.sourceControlHandle, this.handle); + $executeResourceCommand(sourceControlHandle: number, groupHandle: number, handle: number): Promise { + const sourceControl = this.sourceControls.get(sourceControlHandle); + + if (!sourceControl) { + return Promise.resolve(undefined); + } + + const group = sourceControl.getResourceGroup(groupHandle); + + if (!group) { + return Promise.resolve(undefined); + } + + return group.executeResourceCommand(handle); + } + + async $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string, number] | undefined> { + const sourceControl = this.sourceControls.get(sourceControlHandle); + + if (!sourceControl) { + return Promise.resolve(undefined); + } + + if (!sourceControl.inputBox.validateInput) { + return Promise.resolve(undefined); + } + + const result = await sourceControl.inputBox.validateInput!(value, cursorPosition); + if (!result) { + return Promise.resolve(undefined); + } + return [result.message, result.type]; + } + + $setSelectedSourceControl(selectedSourceControlHandle: number | undefined): Promise { + if (selectedSourceControlHandle !== undefined) { + this.sourceControls.get(selectedSourceControlHandle)?.setSelectionState(true); + } + + if (this.selectedSourceControlHandle !== undefined) { + this.sourceControls.get(this.selectedSourceControlHandle)?.setSelectionState(false); + } + + this.selectedSourceControlHandle = selectedSourceControlHandle; + return Promise.resolve(undefined); } } diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 37f1481313abb..0185f11b3d50b 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -104,6 +104,27 @@ export enum ColorThemeKind { HighContrast = 3 } +/** + * Represents the validation type of the Source Control input. + */ +export enum SourceControlInputBoxValidationType { + + /** + * Something not allowed by the rules of a language or other means. + */ + Error = 0, + + /** + * Something suspicious but allowed. + */ + Warning = 1, + + /** + * Something to inform about but not a problem. + */ + Information = 2 +} + export class ColorTheme implements theia.ColorTheme { constructor(public readonly kind: ColorThemeKind) { } } diff --git a/packages/plugin/src/theia-proposed.d.ts b/packages/plugin/src/theia-proposed.d.ts index fef39a9b1d105..d1ab98004bf48 100644 --- a/packages/plugin/src/theia-proposed.d.ts +++ b/packages/plugin/src/theia-proposed.d.ts @@ -229,6 +229,56 @@ declare module '@theia/plugin' { source?: string; } + // #region SCM validation + + /** + * Represents the validation type of the Source Control input. + */ + export enum SourceControlInputBoxValidationType { + + /** + * Something not allowed by the rules of a language or other means. + */ + Error = 0, + + /** + * Something suspicious but allowed. + */ + Warning = 1, + + /** + * Something to inform about but not a problem. + */ + Information = 2 + } + + export interface SourceControlInputBoxValidation { + + /** + * The validation message to display. + */ + readonly message: string; + + /** + * The validation type. + */ + readonly type: SourceControlInputBoxValidationType; + } + + /** + * Represents the input box in the Source Control viewlet. + */ + export interface SourceControlInputBox { + + /** + * A validation function for the input box. It's possible to change + * the validation provider simply by setting this property to a different function. + */ + validateInput?(value: string, cursorPosition: number): ProviderResult; + } + + // #endregion + export interface SourceControl { /** diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 651b18ea31f5e..e690367e60871 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -8411,6 +8411,26 @@ declare module '@theia/plugin' { * resource state. */ readonly decorations?: SourceControlResourceDecorations; + + /** + * Context value of the resource state. This can be used to contribute resource specific actions. + * For example, if a resource is given a context value as `diffable`. When contributing actions to `scm/resourceState/context` + * using `menus` extension point, you can specify context value for key `scmResourceState` in `when` expressions, like `scmResourceState == diffable`. + * ``` + * "contributes": { + * "menus": { + * "scm/resourceState/context": [ + * { + * "command": "extension.diff", + * "when": "scmResourceState == diffable" + * } + * ] + * } + * } + * ``` + * This will show action `extension.diff` only for resources with `contextValue` is `diffable`. + */ + readonly contextValue?: string; } /** diff --git a/packages/scm/src/browser/scm-commit-widget.tsx b/packages/scm/src/browser/scm-commit-widget.tsx index 82e8bcfd91559..5d570cd3f6b5e 100644 --- a/packages/scm/src/browser/scm-commit-widget.tsx +++ b/packages/scm/src/browser/scm-commit-widget.tsx @@ -19,7 +19,7 @@ import { DisposableCollection } from '@theia/core'; import { Message } from '@phosphor/messaging'; import * as React from 'react'; import TextareaAutosize from 'react-autosize-textarea'; -import { ScmInput } from './scm-input'; +import { ScmInput, ScmInputIssueType } from './scm-input'; import { ContextMenuRenderer, ReactWidget, KeybindingRegistry, StatefulWidget } from '@theia/core/lib/browser'; @@ -100,7 +100,20 @@ export class ScmCommitWidget extends ReactWidget implements StatefulWidget { } protected renderInput(input: ScmInput): React.ReactNode { - const validationStatus = input.issue ? input.issue.type : 'idle'; + let validationStatus = 'idle'; + if (input.issue) { + switch (input.issue.type) { + case ScmInputIssueType.Error: + validationStatus = 'error'; + break; + case ScmInputIssueType.Information: + validationStatus = 'info'; + break; + case ScmInputIssueType.Warning: + validationStatus = 'warning'; + break; + } + } const validationMessage = input.issue ? input.issue.message : ''; const format = (value: string, ...args: string[]): string => { if (args.length !== 0) { diff --git a/packages/scm/src/browser/scm-input.ts b/packages/scm/src/browser/scm-input.ts index b1575f277b285..91f1dd9ca43a8 100644 --- a/packages/scm/src/browser/scm-input.ts +++ b/packages/scm/src/browser/scm-input.ts @@ -22,7 +22,13 @@ import { JSONExt, JSONObject } from '@phosphor/coreutils/lib/json'; export interface ScmInputIssue { message: string; - type: 'info' | 'success' | 'warning' | 'error'; + type: ScmInputIssueType; +} + +export enum ScmInputIssueType { + Error = 0, + Warning = 1, + Information = 2 } export interface ScmInputValidator {