diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 7749daed659..1a431a05af7 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -784,7 +784,7 @@ declare module 'vscode' { /** * The validation message to display. */ - readonly message: string; + readonly message: string | MarkdownString; /** * The validation type. @@ -800,7 +800,7 @@ declare module 'vscode' { /** * Shows a transient contextual message on the input. */ - showValidationMessage(message: string, type: SourceControlInputBoxValidationType): void; + showValidationMessage(message: string | MarkdownString, type: SourceControlInputBoxValidationType): void; /** * A validation function for the input box. It's possible to change diff --git a/src/vs/workbench/api/browser/mainThreadSCM.ts b/src/vs/workbench/api/browser/mainThreadSCM.ts index 0c743301f10..03b04b12744 100644 --- a/src/vs/workbench/api/browser/mainThreadSCM.ts +++ b/src/vs/workbench/api/browser/mainThreadSCM.ts @@ -14,6 +14,7 @@ import { ISplice, Sequence } from 'vs/base/common/sequence'; import { CancellationToken } from 'vs/base/common/cancellation'; import { MarshalledId } from 'vs/base/common/marshalling'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; class MainThreadSCMResourceGroup implements ISCMResourceGroup { @@ -438,7 +439,7 @@ export class MainThreadSCM implements MainThreadSCMShape { repository.input.setFocus(); } - $showValidationMessage(sourceControlHandle: number, message: string, type: InputValidationType) { + $showValidationMessage(sourceControlHandle: number, message: string | IMarkdownString, type: InputValidationType) { const repository = this._repositories.get(sourceControlHandle); if (!repository) { return; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b10b05fc1db..a97484b7f56 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1082,7 +1082,7 @@ export interface MainThreadSCMShape extends IDisposable { $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): void; $setInputBoxVisibility(sourceControlHandle: number, visible: boolean): void; $setInputBoxFocus(sourceControlHandle: number): void; - $showValidationMessage(sourceControlHandle: number, message: string, type: InputValidationType): void; + $showValidationMessage(sourceControlHandle: number, message: string | IMarkdownString, type: InputValidationType): void; $setValidationProviderIsEnabled(sourceControlHandle: number, enabled: boolean): void; } @@ -1757,7 +1757,7 @@ export interface ExtHostSCMShape { $provideOriginalResource(sourceControlHandle: number, uri: UriComponents, token: CancellationToken): Promise; $onInputBoxValueChange(sourceControlHandle: number, value: string): void; $executeResourceCommand(sourceControlHandle: number, groupHandle: number, handle: number, preserveFocus: boolean): Promise; - $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string, number] | undefined>; + $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string | IMarkdownString, number] | undefined>; $setSelectedSourceControl(selectedSourceControlHandle: number | undefined): Promise; } diff --git a/src/vs/workbench/api/common/extHostSCM.ts b/src/vs/workbench/api/common/extHostSCM.ts index 142000651bd..089f22519c5 100644 --- a/src/vs/workbench/api/common/extHostSCM.ts +++ b/src/vs/workbench/api/common/extHostSCM.ts @@ -20,6 +20,8 @@ import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensio import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import { MarshalledId } from 'vs/base/common/marshalling'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { MarkdownString } from 'vs/workbench/api/common/extHostTypeConverters'; type ProviderHandle = number; type GroupHandle = number; @@ -275,7 +277,7 @@ export class ExtHostSCMInputBox implements vscode.SourceControlInputBox { this._proxy.$setInputBoxFocus(this._sourceControlHandle); } - showValidationMessage(message: string, type: vscode.SourceControlInputBoxValidationType) { + showValidationMessage(message: string | vscode.MarkdownString, type: vscode.SourceControlInputBoxValidationType) { checkProposedApiEnabled(this._extension); this._proxy.$showValidationMessage(this._sourceControlHandle, message, type as any); @@ -770,7 +772,7 @@ export class ExtHostSCM implements ExtHostSCMShape { return group.$executeResourceCommand(handle, preserveFocus); } - $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string, number] | undefined> { + $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string | IMarkdownString, number] | undefined> { this.logService.trace('ExtHostSCM#$validateInput', sourceControlHandle); const sourceControl = this._sourceControls.get(sourceControlHandle); @@ -788,7 +790,12 @@ export class ExtHostSCM implements ExtHostSCMShape { return Promise.resolve(undefined); } - return Promise.resolve<[string, number]>([result.message, result.type]); + const message = MarkdownString.fromStrict(result.message); + if (!message) { + return Promise.resolve(undefined); + } + + return Promise.resolve<[string | IMarkdownString, number]>([message, result.type]); }); } diff --git a/src/vs/workbench/contrib/scm/browser/media/scm.css b/src/vs/workbench/contrib/scm/browser/media/scm.css index 91599dea2da..4de2e55d1be 100644 --- a/src/vs/workbench/contrib/scm/browser/media/scm.css +++ b/src/vs/workbench/contrib/scm/browser/media/scm.css @@ -215,6 +215,16 @@ border-top: none; } +.scm-editor-validation p { + margin: 0; + padding: 0; +} + +.scm-editor-validation a { + -webkit-user-select: none; + user-select: none; +} + .scm-view .scm-editor-placeholder { position: absolute; pointer-events: none; diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index a65c7d9df3d..1bcf654e407 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -8,7 +8,7 @@ import { Event, Emitter } from 'vs/base/common/event'; import { basename, dirname } from 'vs/base/common/resources'; import { IDisposable, Disposable, DisposableStore, combinedDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle'; import { ViewPane, IViewPaneOptions, ViewAction } from 'vs/workbench/browser/parts/views/viewPane'; -import { append, $, Dimension, asCSSUrl } from 'vs/base/browser/dom'; +import { append, $, Dimension, asCSSUrl, trackFocus } from 'vs/base/browser/dom'; import { IListVirtualDelegate, IIdentityProvider } from 'vs/base/browser/ui/list/list'; import { ISCMResourceGroup, ISCMResource, InputValidationType, ISCMRepository, ISCMInput, IInputValidation, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, SCMInputChangeReason, VIEW_PANE_ID } from 'vs/workbench/contrib/scm/common/scm'; import { ResourceLabels, IResourceLabel } from 'vs/workbench/browser/labels'; @@ -22,7 +22,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { MenuItemAction, IMenuService, registerAction2, MenuId, IAction2Options, MenuRegistry, Action2 } from 'vs/platform/actions/common/actions'; import { IAction, ActionRunner } from 'vs/base/common/actions'; import { ActionBar, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; -import { IThemeService, registerThemingParticipant, IFileIconTheme, ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { IThemeService, registerThemingParticipant, IFileIconTheme, ThemeIcon, IColorTheme, ICssStyleCollector } from 'vs/platform/theme/common/themeService'; import { isSCMResource, isSCMResourceGroup, connectPrimaryMenuToInlineActionBar, isSCMRepository, isSCMInput, collectContextMenuActions, getActionViewItemProvider } from './util'; import { attachBadgeStyler } from 'vs/platform/theme/common/styler'; import { WorkbenchCompressibleObjectTree, IOpenEvent } from 'vs/platform/list/browser/listService'; @@ -56,7 +56,7 @@ import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEdito import { ContextMenuController } from 'vs/editor/contrib/contextmenu/contextmenu'; import * as platform from 'vs/base/common/platform'; import { compare, format } from 'vs/base/common/strings'; -import { inputPlaceholderForeground, inputValidationInfoBorder, inputValidationWarningBorder, inputValidationErrorBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBackground, inputValidationErrorForeground, inputBackground, inputForeground, inputBorder, focusBorder, registerColor, contrastBorder, editorSelectionBackground, selectionBackground } from 'vs/platform/theme/common/colorRegistry'; +import { inputPlaceholderForeground, inputValidationInfoBorder, inputValidationWarningBorder, inputValidationErrorBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBackground, inputValidationErrorForeground, inputBackground, inputForeground, inputBorder, focusBorder, registerColor, contrastBorder, editorSelectionBackground, selectionBackground, textLinkActiveForeground, textLinkForeground } from 'vs/platform/theme/common/colorRegistry'; import { SuggestController } from 'vs/editor/contrib/suggest/suggestController'; import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2'; import { Schemas } from 'vs/base/common/network'; @@ -81,6 +81,7 @@ import { Selection } from 'vs/editor/common/core/selection'; import { API_OPEN_DIFF_EDITOR_COMMAND_ID, API_OPEN_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer'; type TreeElement = ISCMRepository | ISCMInput | ISCMResourceGroup | IResourceNode | ISCMResource; @@ -1470,6 +1471,7 @@ class SCMInputWidget extends Disposable { private validation: IInputValidation | undefined; private validationDisposable: IDisposable = Disposable.None; + private validationHasFocus: boolean = false; private _validationTimer: any; // This is due to "Setup height change listener on next tick" above @@ -1488,7 +1490,7 @@ class SCMInputWidget extends Disposable { return; } - this.validationDisposable.dispose(); + this.clearValidation(); this.editorContainer.classList.remove('synthetic-focus'); this.repositoryDisposables.dispose(); @@ -1556,6 +1558,7 @@ class SCMInputWidget extends Disposable { })); this.repositoryDisposables.add(input.onDidChangeFocus(() => this.focus())); this.repositoryDisposables.add(input.onDidChangeValidationMessage((e) => this.setValidation(e, { focus: true, timeout: true }))); + this.repositoryDisposables.add(input.onDidChangeValidateInput((e) => triggerValidation())); // Keep API in sync with model, update placeholder visibility and validate const updatePlaceholderVisibility = () => this.placeholderTextContainer.classList.toggle('hidden', textModel.getValueLength() > 0); @@ -1640,9 +1643,10 @@ class SCMInputWidget extends Disposable { @IModeService private modeService: IModeService, @IKeybindingService private keybindingService: IKeybindingService, @IConfigurationService private configurationService: IConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, @ISCMViewService private readonly scmViewService: ISCMViewService, - @IContextViewService private readonly contextViewService: IContextViewService + @IContextViewService private readonly contextViewService: IContextViewService, + @IOpenerService private readonly openerService: IOpenerService, ) { super(); @@ -1705,7 +1709,12 @@ class SCMInputWidget extends Disposable { })); this._register(this.inputEditor.onDidBlurEditorText(() => { this.editorContainer.classList.remove('synthetic-focus'); - this.validationDisposable.dispose(); + + setTimeout(() => { + if (!this.validation || !this.validationHasFocus) { + this.clearValidation(); + } + }, 0); })); const firstLineKey = contextKeyService2.createKey('scmInputIsInFirstPosition', false); @@ -1778,7 +1787,7 @@ class SCMInputWidget extends Disposable { } private renderValidation(): void { - this.validationDisposable.dispose(); + this.clearValidation(); this.editorContainer.classList.toggle('validation-info', this.validation?.type === InputValidationType.Information); this.editorContainer.classList.toggle('validation-warning', this.validation?.type === InputValidationType.Warning); @@ -1788,6 +1797,8 @@ class SCMInputWidget extends Disposable { return; } + const disposables = new DisposableStore(); + this.validationDisposable = this.contextViewService.showContextView({ getAnchor: () => this.editorContainer, render: container => { @@ -1796,9 +1807,36 @@ class SCMInputWidget extends Disposable { element.classList.toggle('validation-warning', this.validation!.type === InputValidationType.Warning); element.classList.toggle('validation-error', this.validation!.type === InputValidationType.Error); element.style.width = `${this.editorContainer.clientWidth}px`; - element.textContent = this.validation!.message; + + const message = this.validation!.message; + if (typeof message === 'string') { + element.textContent = message; + } else { + const tracker = trackFocus(element); + disposables.add(tracker); + disposables.add(tracker.onDidFocus(() => (this.validationHasFocus = true))); + disposables.add(tracker.onDidBlur(() => { + this.validationHasFocus = false; + this.contextViewService.hideContextView(); + })); + + const { element: mdElement } = this.instantiationService.createInstance(MarkdownRenderer, {}).render(message, { + actionHandler: { + callback: (content) => { + this.openerService.open(content, { allowCommands: typeof message !== 'string' && message.isTrusted }); + this.contextViewService.hideContextView(); + }, + disposeables: disposables + }, + }); + element.appendChild(mdElement); + } return Disposable.None; }, + onHide: () => { + this.validationHasFocus = false; + disposables.dispose(); + }, anchorAlignment: AnchorAlignment.LEFT }); } @@ -1833,16 +1871,29 @@ class SCMInputWidget extends Disposable { clearValidation(): void { this.validationDisposable.dispose(); + this.validationHasFocus = false; } override dispose(): void { this.input = undefined; this.repositoryDisposables.dispose(); - this.validationDisposable.dispose(); + this.clearValidation(); super.dispose(); } } +registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + const link = theme.getColor(textLinkForeground); + if (link) { + collector.addRule(`.scm-editor-validation a { color: ${link}; }`); + } + + const activeLink = theme.getColor(textLinkActiveForeground); + if (activeLink) { + collector.addRule(`.scm-editor-validation a:active, .scm-editor-validation a:hover { color: ${activeLink}; }`); + } +}); + export class SCMViewPane extends ViewPane { private _onDidLayout: Emitter; diff --git a/src/vs/workbench/contrib/scm/common/scm.ts b/src/vs/workbench/contrib/scm/common/scm.ts index 27a11e4601c..ff7b24c4d68 100644 --- a/src/vs/workbench/contrib/scm/common/scm.ts +++ b/src/vs/workbench/contrib/scm/common/scm.ts @@ -12,6 +12,7 @@ import { ISequence } from 'vs/base/common/sequence'; import { IAction } from 'vs/base/common/actions'; import { IMenu } from 'vs/platform/actions/common/actions'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; export const VIEWLET_ID = 'workbench.view.scm'; export const VIEW_PANE_ID = 'workbench.scm'; @@ -77,7 +78,7 @@ export const enum InputValidationType { } export interface IInputValidation { - message: string; + message: string | IMarkdownString; type: InputValidationType; } @@ -114,7 +115,7 @@ export interface ISCMInput { setFocus(): void; readonly onDidChangeFocus: Event; - showValidationMessage(message: string, type: InputValidationType): void; + showValidationMessage(message: string | IMarkdownString, type: InputValidationType): void; readonly onDidChangeValidationMessage: Event; showNextHistoryValue(): void; diff --git a/src/vs/workbench/contrib/scm/common/scmService.ts b/src/vs/workbench/contrib/scm/common/scmService.ts index a57d65a1aa4..29799b5371f 100644 --- a/src/vs/workbench/contrib/scm/common/scmService.ts +++ b/src/vs/workbench/contrib/scm/common/scmService.ts @@ -10,6 +10,7 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { HistoryNavigator2 } from 'vs/base/common/history'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; class SCMInput implements ISCMInput { @@ -57,7 +58,7 @@ class SCMInput implements ISCMInput { private readonly _onDidChangeFocus = new Emitter(); readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; - showValidationMessage(message: string, type: InputValidationType): void { + showValidationMessage(message: string | IMarkdownString, type: InputValidationType): void { this._onDidChangeValidationMessage.fire({ message: message, type: type }); }