diff --git a/.github/commands.yml b/.github/commands.yml index 83d8c72780..967ef134c0 100644 --- a/.github/commands.yml +++ b/.github/commands.yml @@ -1,16 +1,3 @@ -# { -# perform: true, -# commands: [ -# { -# type: 'comment', -# name: 'findDuplicates', -# allowUsers: ['cleidigh', 'usernamehw', 'gjsjohnmurray', 'IllusionMH'], -# action: 'comment', -# comment: "Potential duplicates:\n${potentialDuplicates}" -# } -# ] -# } - { perform: true, commands: [ diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 3494753b30..df81f2c390 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -122,6 +122,10 @@ "name": "vs/workbench/contrib/preferences", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/notebook", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/quickaccess", "project": "vscode-workbench" diff --git a/src/sql/workbench/services/notebook/browser/notebookService.ts b/src/sql/workbench/services/notebook/browser/notebookService.ts index db1eb215e0..b5c4804d83 100644 --- a/src/sql/workbench/services/notebook/browser/notebookService.ts +++ b/src/sql/workbench/services/notebook/browser/notebookService.ts @@ -19,7 +19,7 @@ import { BaseTextEditor } from 'vs/workbench/browser/parts/editor/textEditor'; import { Range } from 'vs/editor/common/core/range'; import { IStandardKernelWithProvider } from 'sql/workbench/services/notebook/browser/models/notebookUtils'; -export const SERVICE_ID = 'notebookService'; +export const SERVICE_ID = 'sqlNotebookService'; export const INotebookService = createDecorator(SERVICE_ID); export const DEFAULT_NOTEBOOK_PROVIDER = 'builtin'; diff --git a/src/vs/base/browser/ui/codiconLabel/codicon/codicon.css b/src/vs/base/browser/ui/codiconLabel/codicon/codicon.css index 45f6d7b424..c4d7817b82 100644 --- a/src/vs/base/browser/ui/codiconLabel/codicon/codicon.css +++ b/src/vs/base/browser/ui/codiconLabel/codicon/codicon.css @@ -5,7 +5,7 @@ @font-face { font-family: "codicon"; - src: url("./codicon.ttf?5490083fcec741c6a0a08a366d2f9c98") format("truetype"); + src: url("./codicon.ttf?fb4c14f317e1decb0289895ecc9356f0") format("truetype"); } .codicon[class*='codicon-'] { @@ -416,7 +416,8 @@ .codicon-feedback:before { content: "\eb96" } .codicon-group-by-ref-type:before { content: "\eb97" } .codicon-ungroup-by-ref-type:before { content: "\eb98" } -.codicon-bell-dot:before { content: "\f101" } -.codicon-debug-alt-2:before { content: "\f102" } -.codicon-debug-alt:before { content: "\f103" } -.codicon-run-all:before { content: "\f104" } +.codicon-account:before { content: "\f101" } +.codicon-bell-dot:before { content: "\f102" } +.codicon-debug-alt-2:before { content: "\f103" } +.codicon-debug-alt:before { content: "\f104" } +.codicon-run-all:before { content: "\f105" } diff --git a/src/vs/base/browser/ui/codiconLabel/codicon/codicon.ttf b/src/vs/base/browser/ui/codiconLabel/codicon/codicon.ttf index 5eac56a666..2c2e05844c 100644 Binary files a/src/vs/base/browser/ui/codiconLabel/codicon/codicon.ttf and b/src/vs/base/browser/ui/codiconLabel/codicon/codicon.ttf differ diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 5b6f00ff06..2caa6c2b34 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -279,18 +279,32 @@ export class ListView implements ISpliceable, IDisposable { this.scrollableElement.triggerScrollFromMouseWheelEvent(browserEvent); } - updateElementHeight(index: number, size: number): void { + updateElementHeight(index: number, size: number, anchorIndex: number | null): void { if (this.items[index].size === size) { return; } const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); - const heightDiff = index < lastRenderRange.start ? size - this.items[index].size : 0; + let heightDiff = 0; + + if (index < lastRenderRange.start) { + // do not scroll the viewport if resized element is out of viewport + heightDiff = size - this.items[index].size; + } else { + if (anchorIndex !== null && anchorIndex > index && anchorIndex <= lastRenderRange.end) { + // anchor in viewport + // resized elemnet in viewport and above the anchor + heightDiff = size - this.items[index].size; + } else { + heightDiff = 0; + } + } + this.rangeMap.splice(index, 1, [{ size: size }]); this.items[index].size = size; - this.render(lastRenderRange, this.lastRenderTop + heightDiff, this.lastRenderHeight, undefined, undefined, true); + this.render(lastRenderRange, Math.max(0, this.lastRenderTop + heightDiff), this.lastRenderHeight, undefined, undefined, true); this.eventuallyUpdateScrollDimensions(); @@ -1134,6 +1148,10 @@ export class ListView implements ISpliceable, IDisposable { return 0; } + if (!!this.virtualDelegate.hasDynamicHeight && !this.virtualDelegate.hasDynamicHeight(item.element)) { + return 0; + } + const size = item.size; if (!this.setRowHeight && item.row && item.row.domNode) { diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index 2d4f8555cd..532a5dd6cb 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -1314,7 +1314,7 @@ export class List implements ISpliceable, IDisposable { } updateElementHeight(index: number, size: number): void { - this.view.updateElementHeight(index, size); + this.view.updateElementHeight(index, size, null); } rerender(): void { diff --git a/src/vs/base/browser/ui/splitview/paneview.css b/src/vs/base/browser/ui/splitview/paneview.css index add6aa45e9..ccf4a96427 100644 --- a/src/vs/base/browser/ui/splitview/paneview.css +++ b/src/vs/base/browser/ui/splitview/paneview.css @@ -99,3 +99,26 @@ .monaco-pane-view.animated.horizontal .split-view-view { transition-property: width; } + +#monaco-workbench-pane-drop-overlay { + position: absolute; + z-index: 10000; + width: 100%; + height: 100%; + left: 0; + box-sizing: border-box; +} + +#monaco-workbench-pane-drop-overlay > .pane-overlay-indicator { + position: absolute; + width: 100%; + height: 100%; + min-height: 22px; + + pointer-events: none; /* very important to not take events away from the parent */ + transition: opacity 150ms ease-out; +} + +#monaco-workbench-pane-drop-overlay > .pane-overlay-indicator.overlay-move-transition { + transition: top 70ms ease-out, left 70ms ease-out, width 70ms ease-out, height 70ms ease-out, opacity 150ms ease-out; +} diff --git a/src/vs/base/common/collections.ts b/src/vs/base/common/collections.ts index 39522f4d5c..b88075b997 100644 --- a/src/vs/base/common/collections.ts +++ b/src/vs/base/common/collections.ts @@ -20,7 +20,7 @@ const hasOwnProperty = Object.prototype.hasOwnProperty; /** * Returns an array which contains all values that reside - * in the given set. + * in the given dictionary. */ export function values(from: IStringDictionary | INumberDictionary): T[] { const result: T[] = []; @@ -52,7 +52,7 @@ export function first(from: IStringDictionary | INumberDictionary): T | } /** - * Iterates over each entry in the provided set. The iterator allows + * Iterates over each entry in the provided dictionary. The iterator allows * to remove elements and will stop when the callback returns {{false}}. */ export function forEach(from: IStringDictionary, callback: (entry: { key: string; value: T; }, remove: () => void) => any): void; // {{SQL CARBON EDIT}} @anthonydresser add hard typings diff --git a/src/vs/base/common/iterator.ts b/src/vs/base/common/iterator.ts index 0a9c5eb89f..72bc66275c 100644 --- a/src/vs/base/common/iterator.ts +++ b/src/vs/base/common/iterator.ts @@ -49,7 +49,15 @@ export namespace Iterable { return false; } - export function* map(iterable: Iterable, fn: (t: T) => R): IterableIterator { + export function* filter(iterable: Iterable, predicate: (t: T) => boolean): Iterable { + for (const element of iterable) { + if (predicate(element)) { + return yield element; + } + } + } + + export function* map(iterable: Iterable, fn: (t: T) => R): Iterable { for (const element of iterable) { return yield fn(element); } diff --git a/src/vs/base/parts/composite/browser/compositeDnd.ts b/src/vs/base/parts/composite/browser/compositeDnd.ts deleted file mode 100644 index a394a43926..0000000000 --- a/src/vs/base/parts/composite/browser/compositeDnd.ts +++ /dev/null @@ -1,25 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the Source EULA. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IDragAndDropData } from 'vs/base/browser/dnd'; - -export class CompositeDragAndDropData implements IDragAndDropData { - constructor(private type: 'view' | 'composite', private id: string) { } - update(dataTransfer: DataTransfer): void { - // no-op - } - getData(): { - type: 'view' | 'composite'; - id: string; - } { - return { type: this.type, id: this.id }; - } -} - -export interface ICompositeDragAndDrop { - drop(data: IDragAndDropData, target: string | undefined, originalEvent: DragEvent): void; - onDragOver(data: IDragAndDropData, target: string | undefined, originalEvent: DragEvent): boolean; - onDragEnter(data: IDragAndDropData, target: string | undefined, originalEvent: DragEvent): boolean; -} diff --git a/src/vs/base/parts/quickinput/browser/media/quickInput.css b/src/vs/base/parts/quickinput/browser/media/quickInput.css index 69c8161c0e..f1f697abce 100644 --- a/src/vs/base/parts/quickinput/browser/media/quickInput.css +++ b/src/vs/base/parts/quickinput/browser/media/quickInput.css @@ -220,11 +220,20 @@ .quick-input-list .quick-input-list-entry-action-bar { display: flex; - visibility: hidden; /* not using display: none here to not flicker too much */ flex: 0; overflow: visible; } +.quick-input-list .quick-input-list-entry-action-bar .action-label { + /* + * By default, actions in the quick input action bar are hidden + * until hovered over them or selected. We do not use display:none + * so that the amount of visual flickering is little by reserving the + * space the button needs still. + */ + visibility: hidden; +} + .quick-input-list .quick-input-list-entry-action-bar .action-label.codicon { margin: 0; width: 19px; @@ -244,8 +253,8 @@ margin-right: 4px; /* separate actions */ } -.quick-input-list .quick-input-list-entry.always-visible-actions .quick-input-list-entry-action-bar, -.quick-input-list .quick-input-list-entry:hover .quick-input-list-entry-action-bar, -.quick-input-list .monaco-list-row.focused .quick-input-list-entry-action-bar { +.quick-input-list .quick-input-list-entry .quick-input-list-entry-action-bar .action-label.always-visible, +.quick-input-list .quick-input-list-entry:hover .quick-input-list-entry-action-bar .action-label, +.quick-input-list .monaco-list-row.focused .quick-input-list-entry-action-bar .action-label { visibility: visible; } diff --git a/src/vs/base/parts/quickinput/browser/quickInputList.ts b/src/vs/base/parts/quickinput/browser/quickInputList.ts index 26e0817d8b..47e730d804 100644 --- a/src/vs/base/parts/quickinput/browser/quickInputList.ts +++ b/src/vs/base/parts/quickinput/browser/quickInputList.ts @@ -181,7 +181,11 @@ class ListElementRenderer implements IListRenderer { - const action = new Action(`id-${index}`, '', button.iconClass || (button.iconPath ? getIconClass(button.iconPath) : undefined), true, () => { + let cssClasses = button.iconClass || (button.iconPath ? getIconClass(button.iconPath) : undefined); + if (button.alwaysVisible) { + cssClasses = cssClasses ? `${cssClasses} always-visible` : 'always-visible'; + } + const action = new Action(`id-${index}`, '', cssClasses, true, () => { element.fireButtonTriggered({ button, item: element.item @@ -195,12 +199,6 @@ class ListElementRenderer implements IListRenderer { @@ -308,22 +308,35 @@ export type QuickPickInput = T | IQuickPickSeparator; export type IQuickPickItemWithResource = IQuickPickItem & { resource: URI | undefined }; -export const quickPickItemScorerAccessor = new class implements IItemAccessor { +export class QuickPickItemScorerAccessor implements IItemAccessor { + + constructor(private options?: { skipDescription?: boolean, skipPath?: boolean }) { } + getItemLabel(entry: IQuickPickItemWithResource): string { return entry.label; } getItemDescription(entry: IQuickPickItemWithResource): string | undefined { + if (this.options?.skipDescription) { + return undefined; + } + return entry.description; } getItemPath(entry: IQuickPickItemWithResource): string | undefined { + if (this.options?.skipPath) { + return undefined; + } + if (entry.resource?.scheme === Schemas.file) { return entry.resource.fsPath; } return entry.resource?.path; } -}; +} + +export const quickPickItemScorerAccessor = new QuickPickItemScorerAccessor(); //#endregion diff --git a/src/vs/code/browser/workbench/workbench.ts b/src/vs/code/browser/workbench/workbench.ts index badcdd1e9a..9213773c22 100644 --- a/src/vs/code/browser/workbench/workbench.ts +++ b/src/vs/code/browser/workbench/workbench.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IWorkbenchConstructionOptions, create, URI, Event, Emitter, UriComponents, ICredentialsProvider, IURLCallbackProvider, IWorkspaceProvider, IWorkspace, IApplicationLink } from 'vs/workbench/workbench.web.api'; +import { IWorkbenchConstructionOptions, create, URI, Emitter, UriComponents, ICredentialsProvider, IURLCallbackProvider, IWorkspaceProvider, IWorkspace, IApplicationLink } from 'vs/workbench/workbench.web.api'; import { generateUuid } from 'vs/base/common/uuid'; import { CancellationToken } from 'vs/base/common/cancellation'; import { streamToBuffer } from 'vs/base/common/buffer'; @@ -120,8 +120,8 @@ class PollingURLCallbackProvider extends Disposable implements IURLCallbackProvi FRAGMENT: 'vscode-fragment' }; - private readonly _onCallback: Emitter = this._register(new Emitter()); - readonly onCallback: Event = this._onCallback.event; + private readonly _onCallback = this._register(new Emitter()); + readonly onCallback = this._onCallback.event; create(options?: Partial): URI { const queryValues: Map = new Map(); diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index 369dfc4823..3b98b6b3eb 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -1013,6 +1013,16 @@ export function isDiffEditor(thing: any): thing is IDiffEditor { } } +/** + *@internal + */ +export function isCompositeEditor(thing: any): thing is editorCommon.ICompositeCodeEditor { + return thing + && typeof thing === 'object' + && typeof (thing).onDidChangeActiveEditor === 'function'; + +} + /** *@internal */ diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 64dc9e6e0b..deebd6a8d5 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -135,6 +135,11 @@ export interface IEditorOptions { * Defaults to false. */ readOnly?: boolean; + /** + * Rename matching regions on type. + * Defaults to false. + */ + renameOnType?: boolean; /** * Should the editor render validation decorations. * Defaults to editable. @@ -3382,6 +3387,7 @@ export const enum EditorOption { quickSuggestions, quickSuggestionsDelay, readOnly, + renameOnType, renderControlCharacters, renderIndentGuides, renderFinalNewline, @@ -3798,6 +3804,10 @@ export const EditorOptions = { readOnly: register(new EditorBooleanOption( EditorOption.readOnly, 'readOnly', false, )), + renameOnType: register(new EditorBooleanOption( + EditorOption.renameOnType, 'renameOnType', false, + { description: nls.localize('renameOnType', "Controls whether the editor auto renames on type.") } + )), renderControlCharacters: register(new EditorBooleanOption( EditorOption.renderControlCharacters, 'renderControlCharacters', false, { description: nls.localize('renderControlCharacters', "Controls whether the editor should render control characters.") } diff --git a/src/vs/editor/common/editorCommon.ts b/src/vs/editor/common/editorCommon.ts index 222086c089..f040506be0 100644 --- a/src/vs/editor/common/editorCommon.ts +++ b/src/vs/editor/common/editorCommon.ts @@ -5,6 +5,7 @@ import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IDisposable } from 'vs/base/common/lifecycle'; +import { Event } from 'vs/base/common/event'; import { URI, UriComponents } from 'vs/base/common/uri'; import { ConfigurationChangedEvent, IComputedEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IPosition, Position } from 'vs/editor/common/core/position'; @@ -529,6 +530,24 @@ export interface IDiffEditor extends IEditor { getModifiedEditor(): IEditor; } +/** + * @internal + */ +export interface ICompositeCodeEditor { + + /** + * An event that signals that the active editor has changed + */ + readonly onDidChangeActiveEditor: Event; + + /** + * The active code editor iff any + */ + readonly activeCodeEditor: IEditor | undefined; + // readonly editors: readonly ICodeEditor[] maybe supported with uris +} + + /** * An editor contribution that gets created every time a new editor gets created and gets disposed when the editor gets disposed. */ diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 4840313f67..88c2c23b0e 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -627,6 +627,12 @@ export interface ITextModel { */ equalsTextBuffer(other: ITextBuffer): boolean; + /** + * Get the underling text buffer. + * @internal + */ + getTextBuffer(): ITextBuffer; + /** * Get the text in a certain range. * @param range The range describing what text to get. diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index bf9388a08e..939dfb3ce1 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -384,6 +384,11 @@ export class TextModel extends Disposable implements model.ITextModel { return this._buffer.equals(other); } + public getTextBuffer(): model.ITextBuffer { + this._assertNotDisposed(); + return this._buffer; + } + private _emitContentChangedEvent(rawChange: ModelRawContentChangedEvent, change: IModelContentChangedEvent): void { if (this._isDisposing) { // Do not confuse listeners by emitting any event after disposing diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index 49f087130b..7bdaeb7f7a 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -789,6 +789,20 @@ export interface DocumentHighlightProvider { provideDocumentHighlights(model: model.ITextModel, position: Position, token: CancellationToken): ProviderResult; } +/** + * The rename provider interface defines the contract between extensions and + * the live-rename feature. + */ +export interface OnTypeRenameProvider { + + stopPattern?: RegExp; + + /** + * Provide a list of ranges that can be live-renamed together. + */ + provideOnTypeRenameRanges(model: model.ITextModel, position: Position, token: CancellationToken): ProviderResult; +} + /** * Value-object that contains additional information when * requesting references. @@ -1642,6 +1656,11 @@ export const DocumentSymbolProviderRegistry = new LanguageFeatureRegistry(); +/** + * @internal + */ +export const OnTypeRenameProviderRegistry = new LanguageFeatureRegistry(); + /** * @internal */ diff --git a/src/vs/editor/common/standalone/standaloneEnums.ts b/src/vs/editor/common/standalone/standaloneEnums.ts index b923658d2c..d836011d29 100644 --- a/src/vs/editor/common/standalone/standaloneEnums.ts +++ b/src/vs/editor/common/standalone/standaloneEnums.ts @@ -238,47 +238,48 @@ export enum EditorOption { quickSuggestions = 70, quickSuggestionsDelay = 71, readOnly = 72, - renderControlCharacters = 73, - renderIndentGuides = 74, - renderFinalNewline = 75, - renderLineHighlight = 76, - renderValidationDecorations = 77, - renderWhitespace = 78, - revealHorizontalRightPadding = 79, - roundedSelection = 80, - rulers = 81, - scrollbar = 82, - scrollBeyondLastColumn = 83, - scrollBeyondLastLine = 84, - scrollPredominantAxis = 85, - selectionClipboard = 86, - selectionHighlight = 87, - selectOnLineNumbers = 88, - showFoldingControls = 89, - showUnused = 90, - snippetSuggestions = 91, - smoothScrolling = 92, - stopRenderingLineAfter = 93, - suggest = 94, - suggestFontSize = 95, - suggestLineHeight = 96, - suggestOnTriggerCharacters = 97, - suggestSelection = 98, - tabCompletion = 99, - useTabStops = 100, - wordSeparators = 101, - wordWrap = 102, - wordWrapBreakAfterCharacters = 103, - wordWrapBreakBeforeCharacters = 104, - wordWrapColumn = 105, - wordWrapMinified = 106, - wrappingIndent = 107, - wrappingStrategy = 108, - editorClassName = 109, - pixelRatio = 110, - tabFocusMode = 111, - layoutInfo = 112, - wrappingInfo = 113 + renameOnType = 73, + renderControlCharacters = 74, + renderIndentGuides = 75, + renderFinalNewline = 76, + renderLineHighlight = 77, + renderValidationDecorations = 78, + renderWhitespace = 79, + revealHorizontalRightPadding = 80, + roundedSelection = 81, + rulers = 82, + scrollbar = 83, + scrollBeyondLastColumn = 84, + scrollBeyondLastLine = 85, + scrollPredominantAxis = 86, + selectionClipboard = 87, + selectionHighlight = 88, + selectOnLineNumbers = 89, + showFoldingControls = 90, + showUnused = 91, + snippetSuggestions = 92, + smoothScrolling = 93, + stopRenderingLineAfter = 94, + suggest = 95, + suggestFontSize = 96, + suggestLineHeight = 97, + suggestOnTriggerCharacters = 98, + suggestSelection = 99, + tabCompletion = 100, + useTabStops = 101, + wordSeparators = 102, + wordWrap = 103, + wordWrapBreakAfterCharacters = 104, + wordWrapBreakBeforeCharacters = 105, + wordWrapColumn = 106, + wordWrapMinified = 107, + wrappingIndent = 108, + wrappingStrategy = 109, + editorClassName = 110, + pixelRatio = 111, + tabFocusMode = 112, + layoutInfo = 113, + wrappingInfo = 114 } /** diff --git a/src/vs/editor/contrib/find/findDecorations.ts b/src/vs/editor/contrib/find/findDecorations.ts index 8915397931..e026cc31d5 100644 --- a/src/vs/editor/contrib/find/findDecorations.ts +++ b/src/vs/editor/contrib/find/findDecorations.ts @@ -261,7 +261,7 @@ export class FindDecorations implements IDisposable { return result; } - private static readonly _CURRENT_FIND_MATCH_DECORATION = ModelDecorationOptions.register({ + public static readonly _CURRENT_FIND_MATCH_DECORATION = ModelDecorationOptions.register({ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, zIndex: 13, className: 'currentFindMatch', @@ -276,7 +276,7 @@ export class FindDecorations implements IDisposable { } }); - private static readonly _FIND_MATCH_DECORATION = ModelDecorationOptions.register({ + public static readonly _FIND_MATCH_DECORATION = ModelDecorationOptions.register({ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, className: 'findMatch', showIfCollapsed: true, @@ -290,7 +290,7 @@ export class FindDecorations implements IDisposable { } }); - private static readonly _FIND_MATCH_NO_OVERVIEW_DECORATION = ModelDecorationOptions.register({ + public static readonly _FIND_MATCH_NO_OVERVIEW_DECORATION = ModelDecorationOptions.register({ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, className: 'findMatch', showIfCollapsed: true diff --git a/src/vs/workbench/contrib/files/browser/media/fileactions.css b/src/vs/editor/contrib/rename/media/onTypeRename.css similarity index 64% rename from src/vs/workbench/contrib/files/browser/media/fileactions.css rename to src/vs/editor/contrib/rename/media/onTypeRename.css index 4fcfead9b6..aaf96540cf 100644 --- a/src/vs/workbench/contrib/files/browser/media/fileactions.css +++ b/src/vs/editor/contrib/rename/media/onTypeRename.css @@ -3,6 +3,9 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.open-editors .monaco-list .monaco-list-row.dirty:not(:hover) > .monaco-action-bar .codicon-close::before { - content: "\ea71"; +.monaco-editor .on-type-rename-decoration { + background: rgba(255, 0, 0, 0.3); + border-left: 1px solid rgba(255, 0, 0, 0.3); + /* So border can be transparent */ + background-clip: padding-box; } diff --git a/src/vs/editor/contrib/rename/onTypeRename.ts b/src/vs/editor/contrib/rename/onTypeRename.ts new file mode 100644 index 0000000000..25da817b2c --- /dev/null +++ b/src/vs/editor/contrib/rename/onTypeRename.ts @@ -0,0 +1,367 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/onTypeRename'; +import * as nls from 'vs/nls'; +import { registerEditorContribution, registerModelAndPositionCommand, EditorAction, EditorCommand, ServicesAccessor, registerEditorAction, registerEditorCommand } from 'vs/editor/browser/editorExtensions'; +import * as arrays from 'vs/base/common/arrays'; +import { IEditorContribution } from 'vs/editor/common/editorCommon'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { Position, IPosition } from 'vs/editor/common/core/position'; +import { ITextModel, IModelDeltaDecoration, TrackedRangeStickiness, IIdentifiedSingleEditOperation } from 'vs/editor/common/model'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IRange, Range } from 'vs/editor/common/core/range'; +import { OnTypeRenameProviderRegistry } from 'vs/editor/common/modes'; +import { first, createCancelablePromise, CancelablePromise, RunOnceScheduler } from 'vs/base/common/async'; +import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; +import { ContextKeyExpr, RawContextKey, IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { URI } from 'vs/base/common/uri'; +import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { onUnexpectedError, onUnexpectedExternalError } from 'vs/base/common/errors'; +import * as strings from 'vs/base/common/strings'; + +export const CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE = new RawContextKey('onTypeRenameInputVisible', false); + +export class OnTypeRenameContribution extends Disposable implements IEditorContribution { + + public static readonly ID = 'editor.contrib.onTypeRename'; + + private static readonly DECORATION = ModelDecorationOptions.register({ + stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges, + className: 'on-type-rename-decoration' + }); + + static get(editor: ICodeEditor): OnTypeRenameContribution { + return editor.getContribution(OnTypeRenameContribution.ID); + } + + private readonly _editor: ICodeEditor; + private _enabled: boolean; + + private readonly _visibleContextKey: IContextKey; + + private _currentRequest: CancelablePromise<{ + ranges: IRange[], + stopPattern?: RegExp + } | null | undefined> | null; + private _currentDecorations: string[]; // The one at index 0 is the reference one + private _stopPattern: RegExp; + private _ignoreChangeEvent: boolean; + private _updateMirrors: RunOnceScheduler; + + constructor( + editor: ICodeEditor, + @IContextKeyService contextKeyService: IContextKeyService + ) { + super(); + this._editor = editor; + this._enabled = this._editor.getOption(EditorOption.renameOnType); + this._visibleContextKey = CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE.bindTo(contextKeyService); + this._currentRequest = null; + this._currentDecorations = []; + this._stopPattern = /^\s/; + this._ignoreChangeEvent = false; + this._updateMirrors = this._register(new RunOnceScheduler(() => this._doUpdateMirrors(), 0)); + + this._register(this._editor.onDidChangeModel((e) => { + this.stopAll(); + this.run(); + })); + + this._register(this._editor.onDidChangeConfiguration((e) => { + if (e.hasChanged(EditorOption.renameOnType)) { + this._enabled = this._editor.getOption(EditorOption.renameOnType); + this.stopAll(); + this.run(); + } + })); + + this._register(this._editor.onDidChangeCursorPosition((e) => { + // no regions, run + if (this._currentDecorations.length === 0) { + this.run(e.position); + } + + // has cached regions, don't run + if (!this._editor.hasModel()) { + return; + } + if (this._currentDecorations.length === 0) { + return; + } + const model = this._editor.getModel(); + const currentRanges = this._currentDecorations.map(decId => model.getDecorationRange(decId)!); + + // just moving cursor around, don't run again + if (Range.containsPosition(currentRanges[0], e.position)) { + return; + } + + // moving cursor out of primary region, run + this.run(e.position); + })); + + this._register(OnTypeRenameProviderRegistry.onDidChange(() => { + this.run(); + })); + + this._register(this._editor.onDidChangeModelContent((e) => { + if (this._ignoreChangeEvent) { + return; + } + if (!this._editor.hasModel()) { + return; + } + if (this._currentDecorations.length === 0) { + // nothing to do + return; + } + if (e.isUndoing || e.isRedoing) { + return; + } + if (e.changes[0] && this._stopPattern.test(e.changes[0].text)) { + this.stopAll(); + return; + } + this._updateMirrors.schedule(); + })); + } + + private _doUpdateMirrors(): void { + if (!this._editor.hasModel()) { + return; + } + if (this._currentDecorations.length === 0) { + // nothing to do + return; + } + + const model = this._editor.getModel(); + const currentRanges = this._currentDecorations.map(decId => model.getDecorationRange(decId)!); + + const referenceRange = currentRanges[0]; + if (referenceRange.startLineNumber !== referenceRange.endLineNumber) { + return this.stopAll(); + } + + const referenceValue = model.getValueInRange(referenceRange); + if (this._stopPattern.test(referenceValue)) { + return this.stopAll(); + } + + let edits: IIdentifiedSingleEditOperation[] = []; + for (let i = 1, len = currentRanges.length; i < len; i++) { + const mirrorRange = currentRanges[i]; + if (mirrorRange.startLineNumber !== mirrorRange.endLineNumber) { + edits.push({ + range: mirrorRange, + text: referenceValue + }); + } else { + let oldValue = model.getValueInRange(mirrorRange); + let newValue = referenceValue; + let rangeStartColumn = mirrorRange.startColumn; + let rangeEndColumn = mirrorRange.endColumn; + + const commonPrefixLength = strings.commonPrefixLength(oldValue, newValue); + rangeStartColumn += commonPrefixLength; + oldValue = oldValue.substr(commonPrefixLength); + newValue = newValue.substr(commonPrefixLength); + + const commonSuffixLength = strings.commonSuffixLength(oldValue, newValue); + rangeEndColumn -= commonSuffixLength; + oldValue = oldValue.substr(0, oldValue.length - commonSuffixLength); + newValue = newValue.substr(0, newValue.length - commonSuffixLength); + + if (rangeStartColumn !== rangeEndColumn || newValue.length !== 0) { + edits.push({ + range: new Range(mirrorRange.startLineNumber, rangeStartColumn, mirrorRange.endLineNumber, rangeEndColumn), + text: newValue + }); + } + } + } + + if (edits.length === 0) { + return; + } + + try { + this._ignoreChangeEvent = true; + const prevEditOperationType = this._editor._getCursors().getPrevEditOperationType(); + this._editor.executeEdits('onTypeRename', edits); + this._editor._getCursors().setPrevEditOperationType(prevEditOperationType); + } finally { + this._ignoreChangeEvent = false; + } + } + + public dispose(): void { + super.dispose(); + this.stopAll(); + } + + stopAll(): void { + this._visibleContextKey.set(false); + this._currentDecorations = this._editor.deltaDecorations(this._currentDecorations, []); + } + + async run(position: Position | null = this._editor.getPosition(), force = false): Promise { + if (!position) { + return; + } + if (!this._enabled && !force) { + return; + } + if (!this._editor.hasModel()) { + return; + } + + if (this._currentRequest) { + this._currentRequest.cancel(); + this._currentRequest = null; + } + + const model = this._editor.getModel(); + + this._currentRequest = createCancelablePromise(token => getOnTypeRenameRanges(model, position, token)); + try { + const response = await this._currentRequest; + + let ranges: IRange[] = []; + if (response?.ranges) { + ranges = response.ranges; + } + if (response?.stopPattern) { + this._stopPattern = response.stopPattern; + } + + let foundReferenceRange = false; + for (let i = 0, len = ranges.length; i < len; i++) { + if (Range.containsPosition(ranges[i], position)) { + foundReferenceRange = true; + if (i !== 0) { + const referenceRange = ranges[i]; + ranges.splice(i, 1); + ranges.unshift(referenceRange); + } + break; + } + } + + if (!foundReferenceRange) { + // Cannot do on type rename if the ranges are not where the cursor is... + this.stopAll(); + return; + } + + const decorations: IModelDeltaDecoration[] = ranges.map(range => ({ range: range, options: OnTypeRenameContribution.DECORATION })); + this._visibleContextKey.set(true); + this._currentDecorations = this._editor.deltaDecorations(this._currentDecorations, decorations); + } catch (err) { + onUnexpectedError(err); + this.stopAll(); + } + } +} + +export class OnTypeRenameAction extends EditorAction { + constructor() { + super({ + id: 'editor.action.onTypeRename', + label: nls.localize('onTypeRename.label', "On Type Rename Symbol"), + alias: 'On Type Rename Symbol', + precondition: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasRenameProvider), + kbOpts: { + kbExpr: EditorContextKeys.editorTextFocus, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.F2, + weight: KeybindingWeight.EditorContrib + } + }); + } + + runCommand(accessor: ServicesAccessor, args: [URI, IPosition]): void | Promise { + const editorService = accessor.get(ICodeEditorService); + const [uri, pos] = Array.isArray(args) && args || [undefined, undefined]; + + if (URI.isUri(uri) && Position.isIPosition(pos)) { + return editorService.openCodeEditor({ resource: uri }, editorService.getActiveCodeEditor()).then(editor => { + if (!editor) { + return; + } + editor.setPosition(pos); + editor.invokeWithinContext(accessor => { + this.reportTelemetry(accessor, editor); + return this.run(accessor, editor); + }); + }, onUnexpectedError); + } + + return super.runCommand(accessor, args); + } + + run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { + const controller = OnTypeRenameContribution.get(editor); + if (controller) { + return Promise.resolve(controller.run(editor.getPosition(), true)); + } + return Promise.resolve(); + } +} + +const OnTypeRenameCommand = EditorCommand.bindToContribution(OnTypeRenameContribution.get); +registerEditorCommand(new OnTypeRenameCommand({ + id: 'cancelOnTypeRenameInput', + precondition: CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE, + handler: x => x.stopAll(), + kbOpts: { + kbExpr: EditorContextKeys.editorTextFocus, + weight: KeybindingWeight.EditorContrib + 99, + primary: KeyCode.Escape, + secondary: [KeyMod.Shift | KeyCode.Escape] + } +})); + + +export function getOnTypeRenameRanges(model: ITextModel, position: Position, token: CancellationToken): Promise<{ + ranges: IRange[], + stopPattern?: RegExp +} | undefined | null> { + const orderedByScore = OnTypeRenameProviderRegistry.ordered(model); + + // in order of score ask the occurrences provider + // until someone response with a good result + // (good = none empty array) + return first<{ + ranges: IRange[], + stopPattern?: RegExp + } | undefined>(orderedByScore.map(provider => () => { + return Promise.resolve(provider.provideOnTypeRenameRanges(model, position, token)).then((ranges) => { + if (!ranges) { + return undefined; + } + + return { + ranges, + stopPattern: provider.stopPattern + }; + }, (err) => { + onUnexpectedExternalError(err); + return undefined; + }); + + }), result => !!result && arrays.isNonEmptyArray(result?.ranges)); +} + + +registerModelAndPositionCommand('_executeRenameOnTypeProvider', (model, position) => getOnTypeRenameRanges(model, position, CancellationToken.None)); + +registerEditorContribution(OnTypeRenameContribution.ID, OnTypeRenameContribution); +registerEditorAction(OnTypeRenameAction); diff --git a/src/vs/editor/contrib/rename/test/onTypeRename.test.ts b/src/vs/editor/contrib/rename/test/onTypeRename.test.ts new file mode 100644 index 0000000000..2892fd0d5a --- /dev/null +++ b/src/vs/editor/contrib/rename/test/onTypeRename.test.ts @@ -0,0 +1,451 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { Handler } from 'vs/editor/common/editorCommon'; +import * as modes from 'vs/editor/common/modes'; +import { OnTypeRenameContribution } from 'vs/editor/contrib/rename/onTypeRename'; +import { createTestCodeEditor, TestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; +import { createTextModel } from 'vs/editor/test/common/editorTestUtils'; +import { CoreEditingCommands } from 'vs/editor/browser/controller/coreCommands'; + +const mockFile = URI.parse('test:somefile.ttt'); +const mockFileSelector = { scheme: 'test' }; +const timeout = 30; + +suite('On type rename', () => { + const disposables = new DisposableStore(); + + setup(() => { + disposables.clear(); + }); + + teardown(() => { + disposables.clear(); + }); + + function createMockEditor(text: string | string[]) { + const model = typeof text === 'string' + ? createTextModel(text, undefined, undefined, mockFile) + : createTextModel(text.join('\n'), undefined, undefined, mockFile); + + const editor = createTestCodeEditor({ model }); + disposables.add(model); + disposables.add(editor); + + return editor; + } + + + function testCase( + name: string, + initialState: { text: string | string[], ranges: Range[], stopPattern?: RegExp }, + operations: (editor: TestCodeEditor, contrib: OnTypeRenameContribution) => Promise, + expectedEndText: string | string[] + ) { + test(name, async () => { + disposables.add(modes.OnTypeRenameProviderRegistry.register(mockFileSelector, { + stopPattern: initialState.stopPattern || /^\s/, + + provideOnTypeRenameRanges() { + return initialState.ranges; + } + })); + + const editor = createMockEditor(initialState.text); + const ontypeRenameContribution = editor.registerAndInstantiateContribution( + OnTypeRenameContribution.ID, + OnTypeRenameContribution + ); + + await operations(editor, ontypeRenameContribution); + + return new Promise((resolve) => { + setTimeout(() => { + if (typeof expectedEndText === 'string') { + assert.equal(editor.getModel()!.getValue(), expectedEndText); + } else { + assert.equal(editor.getModel()!.getValue(), expectedEndText.join('\n')); + } + resolve(); + }, timeout); + }); + }); + } + + const state = { + text: '', + ranges: [ + new Range(1, 2, 1, 5), + new Range(1, 8, 1, 11), + ] + }; + + /** + * Simple insertion + */ + testCase('Simple insert - initial', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Simple insert - middle', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 3); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Simple insert - end', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + /** + * Simple insertion - end + */ + testCase('Simple insert end - initial', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 8); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Simple insert end - middle', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 9); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Simple insert end - end', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 11); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + /** + * Boundary insertion + */ + testCase('Simple insert - out of boundary', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 1); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, 'i'); + + testCase('Simple insert - out of boundary 2', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 6); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, 'i'); + + testCase('Simple insert - out of boundary 3', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 7); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Simple insert - out of boundary 4', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 12); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, 'i'); + + /** + * Insert + Move + */ + testCase('Continuous insert', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Insert - move - insert', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + editor.setPosition(new Position(1, 4)); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Insert - move - insert outside region', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + editor.setPosition(new Position(1, 7)); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, 'i'); + + /** + * Selection insert + */ + testCase('Selection insert - simple', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.setSelection(new Range(1, 2, 1, 3)); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Selection insert - whole', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.setSelection(new Range(1, 2, 1, 5)); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Selection insert - across boundary', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.setSelection(new Range(1, 1, 1, 3)); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, 'ioo>'); + + /** + * @todo + * Undefined behavior + */ + // testCase('Selection insert - across two boundary', state, async (editor, ontypeRenameContribution) => { + // const pos = new Position(1, 2); + // editor.setPosition(pos); + // await ontypeRenameContribution.run(pos, true); + // editor.setSelection(new Range(1, 4, 1, 9)); + // editor.trigger('keyboard', Handler.Type, { text: 'i' }); + // }, ''); + + /** + * Break out behavior + */ + testCase('Breakout - type space', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: ' ' }); + }, ''); + + testCase('Breakout - type space then undo', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: ' ' }); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + }, ''); + + testCase('Breakout - type space in middle', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 4); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: ' ' }); + }, ''); + + testCase('Breakout - paste content starting with space', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Paste, { text: ' i="i"' }); + }, ''); + + testCase('Breakout - paste content starting with space then undo', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Paste, { text: ' i="i"' }); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + }, ''); + + testCase('Breakout - paste content starting with space in middle', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 4); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Paste, { text: ' i' }); + }, ''); + + /** + * Break out with custom stopPattern + */ + + const state3 = { + ...state, + stopPattern: /^s/ + }; + + testCase('Breakout with stop pattern - insert', state3, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, ''); + + testCase('Breakout with stop pattern - insert stop char', state3, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 's' }); + }, ''); + + testCase('Breakout with stop pattern - paste char', state3, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Paste, { text: 's' }); + }, ''); + + testCase('Breakout with stop pattern - paste string', state3, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Paste, { text: 'so' }); + }, ''); + + testCase('Breakout with stop pattern - insert at end', state3, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 's' }); + }, ''); + + /** + * Delete + */ + testCase('Delete - left char', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', 'deleteLeft', {}); + }, ''); + + testCase('Delete - left char then undo', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', 'deleteLeft', {}); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + }, ''); + + testCase('Delete - left word', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', 'deleteWordLeft', {}); + }, '<>'); + + testCase('Delete - left word then undo', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', 'deleteWordLeft', {}); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + }, ''); + + /** + * Todo: Fix test + */ + // testCase('Delete - left all', state, async (editor, ontypeRenameContribution) => { + // const pos = new Position(1, 3); + // editor.setPosition(pos); + // await ontypeRenameContribution.run(pos, true); + // editor.trigger('keyboard', 'deleteAllLeft', {}); + // }, '>'); + + /** + * Todo: Fix test + */ + // testCase('Delete - left all then undo', state, async (editor, ontypeRenameContribution) => { + // const pos = new Position(1, 5); + // editor.setPosition(pos); + // await ontypeRenameContribution.run(pos, true); + // editor.trigger('keyboard', 'deleteAllLeft', {}); + // CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + // }, '>'); + + testCase('Delete - left all then undo twice', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', 'deleteAllLeft', {}); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + }, ''); + + testCase('Delete - selection', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 5); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.setSelection(new Range(1, 2, 1, 3)); + editor.trigger('keyboard', 'deleteLeft', {}); + }, ''); + + testCase('Delete - selection across boundary', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 3); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.setSelection(new Range(1, 1, 1, 3)); + editor.trigger('keyboard', 'deleteLeft', {}); + }, 'oo>'); + + /** + * Undo / redo + */ + testCase('Undo/redo - simple undo', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + }, ''); + + testCase('Undo/redo - simple undo/redo', state, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + CoreEditingCommands.Undo.runEditorCommand(null, editor, null); + CoreEditingCommands.Redo.runEditorCommand(null, editor, null); + }, ''); + + /** + * Multi line + */ + const state2 = { + text: [ + '', + '' + ], + ranges: [ + new Range(1, 2, 1, 5), + new Range(2, 3, 2, 6), + ] + }; + + testCase('Multiline insert', state2, async (editor, ontypeRenameContribution) => { + const pos = new Position(1, 2); + editor.setPosition(pos); + await ontypeRenameContribution.run(pos, true); + editor.trigger('keyboard', Handler.Type, { text: 'i' }); + }, [ + '', + '' + ]); +}); diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index 24d83b3bbc..98409a378e 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -32,6 +32,7 @@ import 'vs/editor/contrib/linesOperations/linesOperations'; import 'vs/editor/contrib/links/links'; import 'vs/editor/contrib/multicursor/multicursor'; import 'vs/editor/contrib/parameterHints/parameterHints'; +import 'vs/editor/contrib/rename/onTypeRename'; import 'vs/editor/contrib/rename/rename'; import 'vs/editor/contrib/smartSelect/smartSelect'; import 'vs/editor/contrib/snippet/snippetController2'; diff --git a/src/vs/editor/standalone/browser/standaloneLanguages.ts b/src/vs/editor/standalone/browser/standaloneLanguages.ts index 87228b1299..48d8023873 100644 --- a/src/vs/editor/standalone/browser/standaloneLanguages.ts +++ b/src/vs/editor/standalone/browser/standaloneLanguages.ts @@ -391,6 +391,13 @@ export function registerDocumentHighlightProvider(languageId: string, provider: return modes.DocumentHighlightProviderRegistry.register(languageId, provider); } +/** + * Register an on type rename provider. + */ +export function registerOnTypeRenameProvider(languageId: string, provider: modes.OnTypeRenameProvider): IDisposable { + return modes.OnTypeRenameProviderRegistry.register(languageId, provider); +} + /** * Register a definition provider (used by e.g. go to definition). */ @@ -559,6 +566,7 @@ export function createMonacoLanguagesAPI(): typeof monaco.languages { registerHoverProvider: registerHoverProvider, registerDocumentSymbolProvider: registerDocumentSymbolProvider, registerDocumentHighlightProvider: registerDocumentHighlightProvider, + registerOnTypeRenameProvider: registerOnTypeRenameProvider, registerDefinitionProvider: registerDefinitionProvider, registerImplementationProvider: registerImplementationProvider, registerTypeDefinitionProvider: registerTypeDefinitionProvider, diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 9e00d61c79..71f295b3dc 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -2673,6 +2673,11 @@ declare namespace monaco.editor { * Defaults to false. */ readOnly?: boolean; + /** + * Rename matching regions on type. + * Defaults to false. + */ + renameOnType?: boolean; /** * Should the editor render validation decorations. * Defaults to editable. @@ -3886,47 +3891,48 @@ declare namespace monaco.editor { quickSuggestions = 70, quickSuggestionsDelay = 71, readOnly = 72, - renderControlCharacters = 73, - renderIndentGuides = 74, - renderFinalNewline = 75, - renderLineHighlight = 76, - renderValidationDecorations = 77, - renderWhitespace = 78, - revealHorizontalRightPadding = 79, - roundedSelection = 80, - rulers = 81, - scrollbar = 82, - scrollBeyondLastColumn = 83, - scrollBeyondLastLine = 84, - scrollPredominantAxis = 85, - selectionClipboard = 86, - selectionHighlight = 87, - selectOnLineNumbers = 88, - showFoldingControls = 89, - showUnused = 90, - snippetSuggestions = 91, - smoothScrolling = 92, - stopRenderingLineAfter = 93, - suggest = 94, - suggestFontSize = 95, - suggestLineHeight = 96, - suggestOnTriggerCharacters = 97, - suggestSelection = 98, - tabCompletion = 99, - useTabStops = 100, - wordSeparators = 101, - wordWrap = 102, - wordWrapBreakAfterCharacters = 103, - wordWrapBreakBeforeCharacters = 104, - wordWrapColumn = 105, - wordWrapMinified = 106, - wrappingIndent = 107, - wrappingStrategy = 108, - editorClassName = 109, - pixelRatio = 110, - tabFocusMode = 111, - layoutInfo = 112, - wrappingInfo = 113 + renameOnType = 73, + renderControlCharacters = 74, + renderIndentGuides = 75, + renderFinalNewline = 76, + renderLineHighlight = 77, + renderValidationDecorations = 78, + renderWhitespace = 79, + revealHorizontalRightPadding = 80, + roundedSelection = 81, + rulers = 82, + scrollbar = 83, + scrollBeyondLastColumn = 84, + scrollBeyondLastLine = 85, + scrollPredominantAxis = 86, + selectionClipboard = 87, + selectionHighlight = 88, + selectOnLineNumbers = 89, + showFoldingControls = 90, + showUnused = 91, + snippetSuggestions = 92, + smoothScrolling = 93, + stopRenderingLineAfter = 94, + suggest = 95, + suggestFontSize = 96, + suggestLineHeight = 97, + suggestOnTriggerCharacters = 98, + suggestSelection = 99, + tabCompletion = 100, + useTabStops = 101, + wordSeparators = 102, + wordWrap = 103, + wordWrapBreakAfterCharacters = 104, + wordWrapBreakBeforeCharacters = 105, + wordWrapColumn = 106, + wordWrapMinified = 107, + wrappingIndent = 108, + wrappingStrategy = 109, + editorClassName = 110, + pixelRatio = 111, + tabFocusMode = 112, + layoutInfo = 113, + wrappingInfo = 114 } export const EditorOptions: { acceptSuggestionOnCommitCharacter: IEditorOption; @@ -4002,6 +4008,7 @@ declare namespace monaco.editor { quickSuggestions: IEditorOption; quickSuggestionsDelay: IEditorOption; readOnly: IEditorOption; + renameOnType: IEditorOption; renderControlCharacters: IEditorOption; renderIndentGuides: IEditorOption; renderFinalNewline: IEditorOption; @@ -4958,6 +4965,11 @@ declare namespace monaco.languages { */ export function registerDocumentHighlightProvider(languageId: string, provider: DocumentHighlightProvider): IDisposable; + /** + * Register an on type rename provider. + */ + export function registerOnTypeRenameProvider(languageId: string, provider: OnTypeRenameProvider): IDisposable; + /** * Register a definition provider (used by e.g. go to definition). */ @@ -5716,6 +5728,18 @@ declare namespace monaco.languages { provideDocumentHighlights(model: editor.ITextModel, position: Position, token: CancellationToken): ProviderResult; } + /** + * The rename provider interface defines the contract between extensions and + * the live-rename feature. + */ + export interface OnTypeRenameProvider { + stopPattern?: RegExp; + /** + * Provide a list of ranges that can be live-renamed together. + */ + provideOnTypeRenameRanges(model: editor.ITextModel, position: Position, token: CancellationToken): ProviderResult; + } + /** * Value-object that contains additional information when * requesting references. diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 838dc39632..0bd2644919 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -442,14 +442,14 @@ export function registerAction2(ctor: { new(): Action2 }): IDisposable { KeybindingsRegistry.registerKeybindingRule({ ...item, id: command.id, - when: ContextKeyExpr.and(command.precondition, item.when) + when: command.precondition ? ContextKeyExpr.and(command.precondition, item.when) : item.when }); } } else if (keybinding) { KeybindingsRegistry.registerKeybindingRule({ ...keybinding, id: command.id, - when: ContextKeyExpr.and(command.precondition, keybinding.when) + when: command.precondition ? ContextKeyExpr.and(command.precondition, keybinding.when) : keybinding.when }); } diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 09faf5eb26..ab6cb003ae 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -136,8 +136,6 @@ export interface IEnvironmentService extends IUserHomeProvider { // sync resources userDataSyncLogResource: URI; userDataSyncHome: URI; - settingsSyncPreviewResource: URI; - keybindingsSyncPreviewResource: URI; machineSettingsResource: URI; diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts index 1b1e801c39..e80bdba251 100644 --- a/src/vs/platform/environment/node/environmentService.ts +++ b/src/vs/platform/environment/node/environmentService.ts @@ -114,12 +114,6 @@ export class EnvironmentService implements IEnvironmentService { @memoize get userDataSyncHome(): URI { return resources.joinPath(this.userRoamingDataHome, 'sync'); } - @memoize - get settingsSyncPreviewResource(): URI { return resources.joinPath(this.userDataSyncHome, 'settings.json'); } - - @memoize - get keybindingsSyncPreviewResource(): URI { return resources.joinPath(this.userDataSyncHome, 'keybindings.json'); } - @memoize get userDataSyncLogResource(): URI { return URI.file(path.join(this.logsPath, 'userDataSync.log')); } diff --git a/src/vs/platform/progress/common/progress.ts b/src/vs/platform/progress/common/progress.ts index 271f16c80d..997c62504d 100644 --- a/src/vs/platform/progress/common/progress.ts +++ b/src/vs/platform/progress/common/progress.ts @@ -62,6 +62,7 @@ export interface IProgressNotificationOptions extends IProgressOptions { readonly primaryActions?: ReadonlyArray; readonly secondaryActions?: ReadonlyArray; readonly delay?: number; + readonly silent?: boolean; } export interface IProgressWindowOptions extends IProgressOptions { diff --git a/src/vs/platform/quickinput/browser/commandsQuickAccess.ts b/src/vs/platform/quickinput/browser/commandsQuickAccess.ts index 216345cdd2..7aed8b1ed3 100644 --- a/src/vs/platform/quickinput/browser/commandsQuickAccess.ts +++ b/src/vs/platform/quickinput/browser/commandsQuickAccess.ts @@ -5,7 +5,7 @@ import { localize } from 'vs/nls'; import { IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput'; -import { PickerQuickAccessProvider, IPickerQuickAccessItem } from 'vs/platform/quickinput/browser/pickerQuickAccess'; +import { PickerQuickAccessProvider, IPickerQuickAccessItem, IPickerQuickAccessProviderOptions } from 'vs/platform/quickinput/browser/pickerQuickAccess'; import { distinct } from 'vs/base/common/arrays'; import { CancellationToken } from 'vs/base/common/cancellation'; import { DisposableStore, Disposable, IDisposable } from 'vs/base/common/lifecycle'; @@ -30,7 +30,7 @@ export interface ICommandQuickPick extends IPickerQuickAccessItem { commandAlias: string | undefined; } -export interface ICommandsQuickAccessOptions { +export interface ICommandsQuickAccessOptions extends IPickerQuickAccessProviderOptions { showAlias: boolean; } @@ -43,14 +43,14 @@ export abstract class AbstractCommandsQuickAccessProvider extends PickerQuickAcc private readonly commandsHistory = this._register(this.instantiationService.createInstance(CommandsHistory)); constructor( - private options: ICommandsQuickAccessOptions, + protected options: ICommandsQuickAccessOptions, @IInstantiationService private readonly instantiationService: IInstantiationService, @IKeybindingService private readonly keybindingService: IKeybindingService, @ICommandService private readonly commandService: ICommandService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INotificationService private readonly notificationService: INotificationService ) { - super(AbstractCommandsQuickAccessProvider.PREFIX); + super(AbstractCommandsQuickAccessProvider.PREFIX, options); } protected async getPicks(filter: string, disposables: DisposableStore, token: CancellationToken): Promise> { diff --git a/src/vs/platform/quickinput/browser/pickerQuickAccess.ts b/src/vs/platform/quickinput/browser/pickerQuickAccess.ts index 4b3ffc3f32..9960ee0fad 100644 --- a/src/vs/platform/quickinput/browser/pickerQuickAccess.ts +++ b/src/vs/platform/quickinput/browser/pickerQuickAccess.ts @@ -8,6 +8,7 @@ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cance import { IQuickPickSeparator, IKeyMods, IQuickPickAcceptEvent } from 'vs/base/parts/quickinput/common/quickInput'; import { IQuickAccessProvider } from 'vs/platform/quickinput/common/quickAccess'; import { IDisposable, DisposableStore, Disposable } from 'vs/base/common/lifecycle'; +import { timeout } from 'vs/base/common/async'; export enum TriggerAction { @@ -53,17 +54,31 @@ export interface IPickerQuickAccessItem extends IQuickPickItem { trigger?(buttonIndex: number, keyMods: IKeyMods): TriggerAction | Promise; } +export interface IPickerQuickAccessProviderOptions { + canAcceptInBackground?: boolean; +} + +export type FastAndSlowPicksType = { picks: Array, additionalPicks: Promise> }; + +function isFastAndSlowPicksType(obj: unknown): obj is FastAndSlowPicksType { + const candidate = obj as FastAndSlowPicksType; + + return Array.isArray(candidate.picks) && candidate.additionalPicks instanceof Promise; +} + export abstract class PickerQuickAccessProvider extends Disposable implements IQuickAccessProvider { - constructor(private prefix: string) { + private static FAST_PICKS_RACE_DELAY = 200; // timeout before we accept fast results before slow results are present + + constructor(private prefix: string, protected options?: IPickerQuickAccessProviderOptions) { super(); } provide(picker: IQuickPick, token: CancellationToken): IDisposable { const disposables = new DisposableStore(); - // Allow subclasses to configure picker - this.configure(picker); + // Apply options if any + picker.canAcceptInBackground = !!this.options?.canAcceptInBackground; // Disable filtering & sorting, we control the results picker.matchOnLabel = picker.matchOnDescription = picker.matchOnDetail = picker.sortByLabel = false; @@ -79,21 +94,77 @@ export abstract class PickerQuickAccessProvider { + try { + await timeout(PickerQuickAccessProvider.FAST_PICKS_RACE_DELAY); + if (picksToken.isCancellationRequested) { + return; + } + + if (!slowPicksHandlerDone) { + picker.items = res.picks; + } + } finally { + fastPicksHandlerDone = true; + } + })(), + + + // Slow Picks: we await the slow picks and then set them at + // once together with the fast picks, but only if we actually + // have additional results. + (async () => { + picker.busy = true; + try { + const additionalPicks = await res.additionalPicks; + if (picksToken.isCancellationRequested) { + return; + } + + if (additionalPicks.length > 0 || !fastPicksHandlerDone) { + picker.items = [...res.picks, ...additionalPicks]; + } + } finally { + if (!picksToken.isCancellationRequested) { + picker.busy = false; + } + + slowPicksHandlerDone = true; + } + })() + ]); + } + + // Fast Picks + else if (Array.isArray(res)) { picker.items = res; - } else { + } + + // Slow Picks + else { picker.busy = true; try { const items = await res; - if (token.isCancellationRequested) { + if (picksToken.isCancellationRequested) { return; } picker.items = items; } finally { - if (!token.isCancellationRequested) { + if (!picksToken.isCancellationRequested) { picker.busy = false; } } @@ -142,13 +213,6 @@ export abstract class PickerQuickAccessProvider): void { } - /** * Returns an array of picks and separators as needed. If the picks are resolved * long running, the provided cancellation token should be used to cancel the @@ -162,6 +226,7 @@ export abstract class PickerQuickAccessProvider | Promise>; + protected abstract getPicks(filter: string, disposables: DisposableStore, token: CancellationToken): Array | Promise> | FastAndSlowPicksType; } diff --git a/src/vs/platform/undoRedo/common/undoRedoService.ts b/src/vs/platform/undoRedo/common/undoRedoService.ts index 809f398dfd..e6f53068a1 100644 --- a/src/vs/platform/undoRedo/common/undoRedoService.ts +++ b/src/vs/platform/undoRedo/common/undoRedoService.ts @@ -288,8 +288,8 @@ export class UndoRedoService implements IUndoRedoService { Severity.Info, nls.localize('confirmWorkspace', "Would you like to undo '{0}' across all files?", element.label), [ - nls.localize('ok', "Undo In {0} Files", affectedEditStacks.length), - nls.localize('nok', "Undo This File"), + nls.localize('ok', "Undo in {0} Files", affectedEditStacks.length), + nls.localize('nok', "Undo this File"), nls.localize('cancel', "Cancel"), ], { diff --git a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts index 8774a02a3f..3121844870 100644 --- a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts +++ b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts @@ -7,9 +7,9 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { IFileService, IFileContent, FileChangesEvent, FileSystemProviderError, FileSystemProviderErrorCode, FileOperationResult, FileOperationError } from 'vs/platform/files/common/files'; import { VSBuffer } from 'vs/base/common/buffer'; import { URI } from 'vs/base/common/uri'; -import { SyncResource, SyncStatus, IUserData, IUserDataSyncStoreService, UserDataSyncErrorCode, UserDataSyncError, IUserDataSyncLogService, IUserDataSyncUtilService, IUserDataSyncEnablementService, IUserDataSyncBackupStoreService } from 'vs/platform/userDataSync/common/userDataSync'; +import { SyncResource, SyncStatus, IUserData, IUserDataSyncStoreService, UserDataSyncErrorCode, UserDataSyncError, IUserDataSyncLogService, IUserDataSyncUtilService, IUserDataSyncEnablementService, IUserDataSyncBackupStoreService, Conflict } from 'vs/platform/userDataSync/common/userDataSync'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -import { joinPath, dirname } from 'vs/base/common/resources'; +import { joinPath, dirname, isEqual } from 'vs/base/common/resources'; import { CancelablePromise } from 'vs/base/common/async'; import { Emitter, Event } from 'vs/base/common/event'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; @@ -20,6 +20,7 @@ import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { isString } from 'vs/base/common/types'; import { uppercaseFirstLetter } from 'vs/base/common/strings'; +import { equals } from 'vs/base/common/arrays'; type SyncSourceClassification = { source?: { classification: 'SystemMetaData', purpose: 'FeatureInsight', isMeasurement: true }; @@ -51,6 +52,11 @@ export abstract class AbstractSynchroniser extends Disposable { private _onDidChangStatus: Emitter = this._register(new Emitter()); readonly onDidChangeStatus: Event = this._onDidChangStatus.event; + private _conflicts: Conflict[] = []; + get conflicts(): Conflict[] { return this._conflicts; } + private _onDidChangeConflicts: Emitter = this._register(new Emitter()); + readonly onDidChangeConflicts: Event = this._onDidChangeConflicts.event; + protected readonly _onDidChangeLocal: Emitter = this._register(new Emitter()); readonly onDidChangeLocal: Event = this._onDidChangeLocal.event; @@ -87,6 +93,16 @@ export abstract class AbstractSynchroniser extends Disposable { // Log to telemetry when conflicts are resolved this.telemetryService.publicLog2<{ source: string }, SyncSourceClassification>('sync/conflictsResolved', { source: this.resource }); } + if (this.status !== SyncStatus.HasConflicts) { + this.setConflicts([]); + } + } + } + + protected setConflicts(conflicts: Conflict[]) { + if (!equals(this._conflicts, conflicts, (a, b) => isEqual(a.local, b.local) && isEqual(a.remote, b.remote))) { + this._conflicts = conflicts; + this._onDidChangeConflicts.fire(this._conflicts); } } @@ -154,7 +170,7 @@ export abstract class AbstractSynchroniser extends Disposable { return !!lastSyncData; } - async getRemoteContentFromPreview(): Promise { + async getConflictContent(conflictResource: URI): Promise { return null; } @@ -285,15 +301,22 @@ export abstract class AbstractFileSynchroniser extends AbstractSynchroniser { this.cancel(); this.logService.trace(`${this.syncResourceLogLabel}: Stopped synchronizing ${this.resource.toLowerCase()}.`); try { - await this.fileService.del(this.conflictsPreviewResource); + await this.fileService.del(this.localPreviewResource); } catch (e) { /* ignore */ } this.setStatus(SyncStatus.Idle); } - async getRemoteContentFromPreview(): Promise { - if (this.syncPreviewResultPromise) { - const result = await this.syncPreviewResultPromise; - return result.remoteUserData && result.remoteUserData.syncData ? result.remoteUserData.syncData.content : null; + async getConflictContent(conflictResource: URI): Promise { + if (isEqual(this.remotePreviewResource, conflictResource) || isEqual(this.localPreviewResource, conflictResource)) { + if (this.syncPreviewResultPromise) { + const result = await this.syncPreviewResultPromise; + if (isEqual(this.remotePreviewResource, conflictResource)) { + return result.remoteUserData && result.remoteUserData.syncData ? result.remoteUserData.syncData.content : null; + } + if (isEqual(this.localPreviewResource, conflictResource)) { + return result.fileContent ? result.fileContent.value.toString() : null; + } + } } return null; } @@ -356,7 +379,8 @@ export abstract class AbstractFileSynchroniser extends AbstractSynchroniser { } } - protected abstract readonly conflictsPreviewResource: URI; + protected abstract readonly localPreviewResource: URI; + protected abstract readonly remotePreviewResource: URI; } export abstract class AbstractJsonFileSynchroniser extends AbstractFileSynchroniser { diff --git a/src/vs/platform/userDataSync/common/extensionsSync.ts b/src/vs/platform/userDataSync/common/extensionsSync.ts index 8d72b54527..b76dc0be2a 100644 --- a/src/vs/platform/userDataSync/common/extensionsSync.ts +++ b/src/vs/platform/userDataSync/common/extensionsSync.ts @@ -11,11 +11,11 @@ import { ExtensionType, IExtensionIdentifier } from 'vs/platform/extensions/comm import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IFileService } from 'vs/platform/files/common/files'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { localize } from 'vs/nls'; import { merge } from 'vs/platform/userDataSync/common/extensionsMerge'; import { isNonEmptyArray } from 'vs/base/common/arrays'; import { AbstractSynchroniser, IRemoteUserData, ISyncData } from 'vs/platform/userDataSync/common/abstractSynchronizer'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { URI } from 'vs/base/common/uri'; interface ISyncPreviewResult { readonly localExtensions: ISyncExtension[]; @@ -147,7 +147,7 @@ export class ExtensionsSynchroniser extends AbstractSynchroniser implements IUse return null; } - accept(content: string): Promise { + async acceptConflict(conflict: URI, content: string): Promise { throw new Error(`${this.syncResourceLogLabel}: Conflicts should not occur`); } @@ -230,9 +230,9 @@ export class ExtensionsSynchroniser extends AbstractSynchroniser implements IUse const installedExtensions = await this.extensionManagementService.getInstalled(ExtensionType.User); const extensionsToRemove = installedExtensions.filter(({ identifier }) => removed.some(r => areSameExtensions(identifier, r))); await Promise.all(extensionsToRemove.map(async extensionToRemove => { - this.logService.trace(`${this.syncResourceLogLabel}: Uninstalling local extension...', extensionToRemove.identifier.i`); + this.logService.trace(`${this.syncResourceLogLabel}: Uninstalling local extension...`, extensionToRemove.identifier.id); await this.extensionManagementService.uninstall(extensionToRemove); - this.logService.info(`${this.syncResourceLogLabel}: Uninstalled local extension.', extensionToRemove.identifier.i`); + this.logService.info(`${this.syncResourceLogLabel}: Uninstalled local extension.`, extensionToRemove.identifier.id); removeFromSkipped.push(extensionToRemove.identifier); })); } @@ -245,13 +245,13 @@ export class ExtensionsSynchroniser extends AbstractSynchroniser implements IUse // Builtin Extension: Sync only enablement state if (installedExtension && installedExtension.type === ExtensionType.System) { if (e.disabled) { - this.logService.trace(`${this.syncResourceLogLabel}: Disabling extension...', e.identifier.i`); + this.logService.trace(`${this.syncResourceLogLabel}: Disabling extension...`, e.identifier.id); await this.extensionEnablementService.disableExtension(e.identifier); - this.logService.info(`${this.syncResourceLogLabel}: Disabled extension', e.identifier.i`); + this.logService.info(`${this.syncResourceLogLabel}: Disabled extension`, e.identifier.id); } else { - this.logService.trace(`${this.syncResourceLogLabel}: Enabling extension...', e.identifier.i`); + this.logService.trace(`${this.syncResourceLogLabel}: Enabling extension...`, e.identifier.id); await this.extensionEnablementService.enableExtension(e.identifier); - this.logService.info(`${this.syncResourceLogLabel}: Enabled extension', e.identifier.i`); + this.logService.info(`${this.syncResourceLogLabel}: Enabled extension`, e.identifier.id); } removeFromSkipped.push(e.identifier); return; @@ -261,25 +261,25 @@ export class ExtensionsSynchroniser extends AbstractSynchroniser implements IUse if (extension) { try { if (e.disabled) { - this.logService.trace(`${this.syncResourceLogLabel}: Disabling extension...', e.identifier.id, extension.versio`); + this.logService.trace(`${this.syncResourceLogLabel}: Disabling extension...`, e.identifier.id, extension.version); await this.extensionEnablementService.disableExtension(extension.identifier); - this.logService.info(`${this.syncResourceLogLabel}: Disabled extension', e.identifier.id, extension.versio`); + this.logService.info(`${this.syncResourceLogLabel}: Disabled extension`, e.identifier.id, extension.version); } else { - this.logService.trace(`${this.syncResourceLogLabel}: Enabling extension...', e.identifier.id, extension.versio`); + this.logService.trace(`${this.syncResourceLogLabel}: Enabling extension...`, e.identifier.id, extension.version); await this.extensionEnablementService.enableExtension(extension.identifier); - this.logService.info(`${this.syncResourceLogLabel}: Enabled extension', e.identifier.id, extension.versio`); + this.logService.info(`${this.syncResourceLogLabel}: Enabled extension`, e.identifier.id, extension.version); } // Install only if the extension does not exist if (!installedExtension || installedExtension.manifest.version !== extension.version) { - this.logService.trace(`${this.syncResourceLogLabel}: Installing extension...', e.identifier.id, extension.versio`); + this.logService.trace(`${this.syncResourceLogLabel}: Installing extension...`, e.identifier.id, extension.version); await this.extensionManagementService.installFromGallery(extension); - this.logService.info(`${this.syncResourceLogLabel}: Installed extension.', e.identifier.id, extension.versio`); + this.logService.info(`${this.syncResourceLogLabel}: Installed extension.`, e.identifier.id, extension.version); removeFromSkipped.push(extension.identifier); } } catch (error) { addToSkipped.push(e); this.logService.error(error); - this.logService.info(localize('skip extension', "Skipped synchronizing extension {0}", extension.displayName || extension.identifier.id)); + this.logService.info(`${this.syncResourceLogLabel}: Skipped synchronizing extension`, extension.displayName || extension.identifier.id); } } else { addToSkipped.push(e); diff --git a/src/vs/platform/userDataSync/common/globalStateSync.ts b/src/vs/platform/userDataSync/common/globalStateSync.ts index 990487cd56..3b2188888f 100644 --- a/src/vs/platform/userDataSync/common/globalStateSync.ts +++ b/src/vs/platform/userDataSync/common/globalStateSync.ts @@ -16,6 +16,7 @@ import { parse } from 'vs/base/common/json'; import { AbstractSynchroniser, IRemoteUserData } from 'vs/platform/userDataSync/common/abstractSynchronizer'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { URI } from 'vs/base/common/uri'; const argvProperties: string[] = ['locale']; @@ -131,7 +132,7 @@ export class GlobalStateSynchroniser extends AbstractSynchroniser implements IUs return null; } - accept(content: string): Promise { + async acceptConflict(conflict: URI, content: string): Promise { throw new Error(`${this.syncResourceLogLabel}: Conflicts should not occur`); } diff --git a/src/vs/platform/userDataSync/common/keybindingsSync.ts b/src/vs/platform/userDataSync/common/keybindingsSync.ts index e7e6f8fe97..b91820c0cf 100644 --- a/src/vs/platform/userDataSync/common/keybindingsSync.ts +++ b/src/vs/platform/userDataSync/common/keybindingsSync.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IFileService, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; -import { UserDataSyncError, UserDataSyncErrorCode, SyncStatus, IUserDataSyncStoreService, IUserDataSyncLogService, IUserDataSyncUtilService, SyncResource, IUserDataSynchroniser, IUserDataSyncEnablementService, IUserDataSyncBackupStoreService } from 'vs/platform/userDataSync/common/userDataSync'; +import { UserDataSyncError, UserDataSyncErrorCode, SyncStatus, IUserDataSyncStoreService, IUserDataSyncLogService, IUserDataSyncUtilService, SyncResource, IUserDataSynchroniser, IUserDataSyncEnablementService, IUserDataSyncBackupStoreService, USER_DATA_SYNC_SCHEME, PREVIEW_DIR_NAME } from 'vs/platform/userDataSync/common/userDataSync'; import { merge } from 'vs/platform/userDataSync/common/keybindingsMerge'; import { VSBuffer } from 'vs/base/common/buffer'; import { parse } from 'vs/base/common/json'; @@ -19,6 +19,7 @@ import { isNonEmptyArray } from 'vs/base/common/arrays'; import { IFileSyncPreviewResult, AbstractJsonFileSynchroniser, IRemoteUserData } from 'vs/platform/userDataSync/common/abstractSynchronizer'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { URI } from 'vs/base/common/uri'; +import { joinPath, isEqual } from 'vs/base/common/resources'; interface ISyncContent { mac?: string; @@ -29,8 +30,9 @@ interface ISyncContent { export class KeybindingsSynchroniser extends AbstractJsonFileSynchroniser implements IUserDataSynchroniser { - protected get conflictsPreviewResource(): URI { return this.environmentService.keybindingsSyncPreviewResource; } protected readonly version: number = 1; + protected readonly localPreviewResource: URI = joinPath(this.syncFolder, PREVIEW_DIR_NAME, 'keybindings.json'); + protected readonly remotePreviewResource: URI = this.localPreviewResource.with({ scheme: USER_DATA_SYNC_SCHEME }); constructor( @IUserDataSyncStoreService userDataSyncStoreService: IUserDataSyncStoreService, @@ -39,7 +41,7 @@ export class KeybindingsSynchroniser extends AbstractJsonFileSynchroniser implem @IConfigurationService configurationService: IConfigurationService, @IUserDataSyncEnablementService userDataSyncEnablementService: IUserDataSyncEnablementService, @IFileService fileService: IFileService, - @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IEnvironmentService environmentService: IEnvironmentService, @IUserDataSyncUtilService userDataSyncUtilService: IUserDataSyncUtilService, @ITelemetryService telemetryService: ITelemetryService, ) { @@ -129,8 +131,10 @@ export class KeybindingsSynchroniser extends AbstractJsonFileSynchroniser implem } - async accept(content: string): Promise { - if (this.status === SyncStatus.HasConflicts) { + async acceptConflict(conflict: URI, content: string): Promise { + if (this.status === SyncStatus.HasConflicts + && (isEqual(this.localPreviewResource, conflict) || isEqual(this.remotePreviewResource, conflict)) + ) { const preview = await this.syncPreviewResultPromise!; this.cancel(); this.syncPreviewResultPromise = createCancelablePromise(async () => ({ ...preview, content })); @@ -156,8 +160,8 @@ export class KeybindingsSynchroniser extends AbstractJsonFileSynchroniser implem return false; } - async getRemoteContentFromPreview(): Promise { - const content = await super.getRemoteContentFromPreview(); + async getConflictContent(conflictResource: URI): Promise { + const content = await super.getConflictContent(conflictResource); return content !== null ? this.getKeybindingsContentFromSyncContent(content) : null; } @@ -224,7 +228,9 @@ export class KeybindingsSynchroniser extends AbstractJsonFileSynchroniser implem if (hasLocalChanged) { this.logService.trace(`${this.syncResourceLogLabel}: Updating local keybindings...`); - await this.backupLocal(this.toSyncContent(content, null)); + if (fileContent) { + await this.backupLocal(this.toSyncContent(fileContent.value.toString(), null)); + } await this.updateLocalFileContent(content, fileContent); this.logService.info(`${this.syncResourceLogLabel}: Updated local keybindings`); } @@ -238,7 +244,7 @@ export class KeybindingsSynchroniser extends AbstractJsonFileSynchroniser implem // Delete the preview try { - await this.fileService.del(this.conflictsPreviewResource); + await this.fileService.del(this.localPreviewResource); } catch (e) { /* ignore */ } } else { this.logService.info(`${this.syncResourceLogLabel}: No changes found during synchronizing keybindings.`); @@ -303,9 +309,11 @@ export class KeybindingsSynchroniser extends AbstractJsonFileSynchroniser implem } if (content && !token.isCancellationRequested) { - await this.fileService.writeFile(this.environmentService.keybindingsSyncPreviewResource, VSBuffer.fromString(content)); + await this.fileService.writeFile(this.localPreviewResource, VSBuffer.fromString(content)); } + this.setConflicts(hasConflicts && !token.isCancellationRequested ? [{ local: this.localPreviewResource, remote: this.remotePreviewResource }] : []); + return { fileContent, remoteUserData, lastSyncUserData, content, hasLocalChanged, hasRemoteChanged, hasConflicts }; } diff --git a/src/vs/platform/userDataSync/common/settingsSync.ts b/src/vs/platform/userDataSync/common/settingsSync.ts index da0775fc42..93d7fef82d 100644 --- a/src/vs/platform/userDataSync/common/settingsSync.ts +++ b/src/vs/platform/userDataSync/common/settingsSync.ts @@ -4,24 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import { IFileService, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; -import { UserDataSyncError, UserDataSyncErrorCode, SyncStatus, IUserDataSyncStoreService, IUserDataSyncLogService, IUserDataSyncUtilService, IConflictSetting, CONFIGURATION_SYNC_STORE_KEY, SyncResource, IUserDataSyncEnablementService, IUserDataSyncBackupStoreService } from 'vs/platform/userDataSync/common/userDataSync'; +import { UserDataSyncError, UserDataSyncErrorCode, SyncStatus, IUserDataSyncStoreService, IUserDataSyncLogService, IUserDataSyncUtilService, CONFIGURATION_SYNC_STORE_KEY, SyncResource, IUserDataSyncEnablementService, IUserDataSyncBackupStoreService, USER_DATA_SYNC_SCHEME, PREVIEW_DIR_NAME } from 'vs/platform/userDataSync/common/userDataSync'; import { VSBuffer } from 'vs/base/common/buffer'; import { parse } from 'vs/base/common/json'; import { localize } from 'vs/nls'; -import { Emitter, Event } from 'vs/base/common/event'; +import { Event } from 'vs/base/common/event'; import { createCancelablePromise } from 'vs/base/common/async'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { CancellationToken } from 'vs/base/common/cancellation'; import { updateIgnoredSettings, merge, getIgnoredSettings } from 'vs/platform/userDataSync/common/settingsMerge'; -import * as arrays from 'vs/base/common/arrays'; -import * as objects from 'vs/base/common/objects'; import { isEmptyObject } from 'vs/base/common/types'; import { edit } from 'vs/platform/userDataSync/common/content'; import { IFileSyncPreviewResult, AbstractJsonFileSynchroniser, IRemoteUserData } from 'vs/platform/userDataSync/common/abstractSynchronizer'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { URI } from 'vs/base/common/uri'; import { IExtensionManagementService } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { joinPath, isEqual } from 'vs/base/common/resources'; export interface ISettingsSyncContent { settings: string; @@ -38,16 +37,12 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { _serviceBrand: any; protected readonly version: number = 1; - protected get conflictsPreviewResource(): URI { return this.environmentService.settingsSyncPreviewResource; } - - private _conflicts: IConflictSetting[] = []; - get conflicts(): IConflictSetting[] { return this._conflicts; } - private _onDidChangeConflicts: Emitter = this._register(new Emitter()); - readonly onDidChangeConflicts: Event = this._onDidChangeConflicts.event; + protected readonly localPreviewResource: URI = joinPath(this.syncFolder, PREVIEW_DIR_NAME, 'settings.json'); + protected readonly remotePreviewResource: URI = this.localPreviewResource.with({ scheme: USER_DATA_SYNC_SCHEME }); constructor( @IFileService fileService: IFileService, - @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IEnvironmentService environmentService: IEnvironmentService, @IUserDataSyncStoreService userDataSyncStoreService: IUserDataSyncStoreService, @IUserDataSyncBackupStoreService userDataSyncBackupStoreService: IUserDataSyncBackupStoreService, @IUserDataSyncLogService logService: IUserDataSyncLogService, @@ -67,15 +62,6 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { } } - private setConflicts(conflicts: IConflictSetting[]): void { - if (!arrays.equals(this.conflicts, conflicts, - (a, b) => a.key === b.key && objects.equals(a.localValue, b.localValue) && objects.equals(a.remoteValue, b.remoteValue)) - ) { - this._conflicts = conflicts; - this._onDidChangeConflicts.fire(conflicts); - } - } - async pull(): Promise { if (!this.isEnabled()) { this.logService.info(`${this.syncResourceLogLabel}: Skipped pulling settings as it is disabled.`); @@ -187,8 +173,8 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { return false; } - async getRemoteContentFromPreview(): Promise { - let content = await super.getRemoteContentFromPreview(); + async getConflictContent(conflictResource: URI): Promise { + let content = await super.getConflictContent(conflictResource); if (content !== null) { const settingsSyncContent = this.parseSettingsSyncContent(content); content = settingsSyncContent ? settingsSyncContent.settings : null; @@ -232,8 +218,10 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { return null; } - async accept(content: string): Promise { - if (this.status === SyncStatus.HasConflicts) { + async acceptConflict(conflict: URI, content: string): Promise { + if (this.status === SyncStatus.HasConflicts + && (isEqual(this.localPreviewResource, conflict) || isEqual(this.remotePreviewResource, conflict)) + ) { const preview = await this.syncPreviewResultPromise!; this.cancel(); const formatUtils = await this.getFormattingOptions(); @@ -289,7 +277,9 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { if (hasLocalChanged) { this.logService.trace(`${this.syncResourceLogLabel}: Updating local settings...`); - await this.backupLocal(JSON.stringify(this.toSettingsSyncContent(content))); + if (fileContent) { + await this.backupLocal(JSON.stringify(this.toSettingsSyncContent(fileContent.value.toString()))); + } await this.updateLocalFileContent(content, fileContent); this.logService.info(`${this.syncResourceLogLabel}: Updated local settings`); } @@ -306,7 +296,7 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { // Delete the preview try { - await this.fileService.del(this.conflictsPreviewResource); + await this.fileService.del(this.localPreviewResource); } catch (e) { /* ignore */ } } else { this.logService.info(`${this.syncResourceLogLabel}: No changes found during synchronizing settings.`); @@ -338,7 +328,6 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { let hasLocalChanged: boolean = false; let hasRemoteChanged: boolean = false; let hasConflicts: boolean = false; - let conflictSettings: IConflictSetting[] = []; if (remoteSettingsSyncContent) { const localContent: string = fileContent ? fileContent.value.toString() : '{}'; @@ -350,7 +339,6 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { hasLocalChanged = result.localContent !== null; hasRemoteChanged = result.remoteContent !== null; hasConflicts = result.hasConflicts; - conflictSettings = result.conflictsSettings; } // First time syncing to remote @@ -364,10 +352,11 @@ export class SettingsSynchroniser extends AbstractJsonFileSynchroniser { // Remove the ignored settings from the preview. const ignoredSettings = await this.getIgnoredSettings(); const previewContent = updateIgnoredSettings(content, '{}', ignoredSettings, formattingOptions); - await this.fileService.writeFile(this.environmentService.settingsSyncPreviewResource, VSBuffer.fromString(previewContent)); + await this.fileService.writeFile(this.localPreviewResource, VSBuffer.fromString(previewContent)); } - this.setConflicts(conflictSettings); + this.setConflicts(hasConflicts && !token.isCancellationRequested ? [{ local: this.localPreviewResource, remote: this.remotePreviewResource }] : []); + return { fileContent, remoteUserData, lastSyncUserData, content, hasLocalChanged, hasRemoteChanged, hasConflicts }; } diff --git a/src/vs/platform/userDataSync/common/userDataSync.ts b/src/vs/platform/userDataSync/common/userDataSync.ts index 0482dd95c8..62bcefacca 100644 --- a/src/vs/platform/userDataSync/common/userDataSync.ts +++ b/src/vs/platform/userDataSync/common/userDataSync.ts @@ -18,7 +18,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IStringDictionary } from 'vs/base/common/collections'; import { FormattingOptions } from 'vs/base/common/jsonFormatter'; import { URI } from 'vs/base/common/uri'; -import { isEqual, joinPath, dirname, basename } from 'vs/base/common/resources'; +import { joinPath, dirname, basename, isEqualOrParent } from 'vs/base/common/resources'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IProductService } from 'vs/platform/product/common/productService'; import { distinct } from 'vs/base/common/arrays'; @@ -242,11 +242,15 @@ export const enum SyncStatus { HasConflicts = 'hasConflicts', } +export type Conflict = { remote: URI, local: URI }; + export interface IUserDataSynchroniser { readonly resource: SyncResource; readonly status: SyncStatus; readonly onDidChangeStatus: Event; + readonly conflicts: Conflict[]; + readonly onDidChangeConflicts: Event; readonly onDidChangeLocal: Event; pull(): Promise; @@ -258,10 +262,11 @@ export interface IUserDataSynchroniser { hasLocalData(): Promise; resetLocal(): Promise; - getRemoteContentFromPreview(): Promise; + getConflictContent(conflictResource: URI): Promise; + acceptConflict(conflictResource: URI, content: string): Promise; + getRemoteContent(ref?: string, fragment?: string): Promise; getLocalBackupContent(ref?: string, fragment?: string): Promise; - accept(content: string): Promise; } //#endregion @@ -282,6 +287,8 @@ export interface IUserDataSyncEnablementService { setResourceEnablement(resource: SyncResource, enabled: boolean): void; } +export type SyncResourceConflicts = { syncResource: SyncResource, conflicts: Conflict[] }; + export const IUserDataSyncService = createDecorator('IUserDataSyncService'); export interface IUserDataSyncService { _serviceBrand: any; @@ -289,8 +296,8 @@ export interface IUserDataSyncService { readonly status: SyncStatus; readonly onDidChangeStatus: Event; - readonly conflictsSources: SyncResource[]; - readonly onDidChangeConflicts: Event; + readonly conflicts: SyncResourceConflicts[]; + readonly onDidChangeConflicts: Event; readonly onDidChangeLocal: Event; readonly onSyncErrors: Event<[SyncResource, UserDataSyncError][]>; @@ -306,7 +313,7 @@ export interface IUserDataSyncService { isFirstTimeSyncWithMerge(): Promise; resolveContent(resource: URI): Promise; - accept(source: SyncResource, content: string): Promise; + acceptConflict(conflictResource: URI, content: string): Promise; } export const IUserDataAutoSyncService = createDecorator('IUserDataAutoSyncService'); @@ -335,36 +342,41 @@ export interface IConflictSetting { //#endregion +export const USER_DATA_SYNC_SCHEME = 'vscode-userdata-sync'; export const CONTEXT_SYNC_STATE = new RawContextKey('syncStatus', SyncStatus.Uninitialized); export const CONTEXT_SYNC_ENABLEMENT = new RawContextKey('syncEnabled', false); -export const USER_DATA_SYNC_SCHEME = 'vscode-userdata-sync'; -export const PREVIEW_QUERY = 'preview=true'; -export function toRemoteSyncResource(resource: SyncResource, ref?: string): URI { - return URI.from({ scheme: USER_DATA_SYNC_SCHEME, authority: 'remote', path: `/${resource}/${ref ? ref : 'latest'}` }); +export function toRemoteBackupSyncResource(resource: SyncResource, ref?: string): URI { + return URI.from({ scheme: USER_DATA_SYNC_SCHEME, authority: 'remote-backup', path: `/${resource}/${ref ? ref : 'latest'}` }); } export function toLocalBackupSyncResource(resource: SyncResource, ref?: string): URI { return URI.from({ scheme: USER_DATA_SYNC_SCHEME, authority: 'local-backup', path: `/${resource}/${ref ? ref : 'latest'}` }); } - -export function resolveSyncResource(resource: URI): { remote: boolean, resource: SyncResource, ref?: string } | null { - if (resource.scheme === USER_DATA_SYNC_SCHEME) { - const remote = resource.authority === 'remote'; +export function resolveBackupSyncResource(resource: URI): { remote: boolean, resource: SyncResource, path: string } | null { + if (resource.scheme === USER_DATA_SYNC_SCHEME + && resource.authority === 'remote-backup' || resource.authority === 'local-backup') { const resourceKey: SyncResource = basename(dirname(resource)) as SyncResource; - const ref = basename(resource); - if (resourceKey && ref) { - return { remote, resource: resourceKey, ref: ref !== 'latest' ? ref : undefined }; + const path = resource.path.substring(resourceKey.length + 1); + if (resourceKey && path) { + const remote = resource.authority === 'remote-backup'; + return { remote, resource: resourceKey, path }; } } return null; } -export function getSyncSourceFromPreviewResource(uri: URI, environmentService: IEnvironmentService): SyncResource | undefined { - if (isEqual(uri, environmentService.settingsSyncPreviewResource)) { - return SyncResource.Settings; +export const PREVIEW_DIR_NAME = 'preview'; +export function getSyncResourceFromLocalPreview(localPreview: URI, environmentService: IEnvironmentService): SyncResource | undefined { + if (localPreview.scheme === USER_DATA_SYNC_SCHEME) { + return undefined; } - if (isEqual(uri, environmentService.keybindingsSyncPreviewResource)) { - return SyncResource.Keybindings; - } - return undefined; + localPreview = localPreview.with({ scheme: environmentService.userDataSyncHome.scheme }); + return ALL_SYNC_RESOURCES.filter(syncResource => isEqualOrParent(localPreview, joinPath(environmentService.userDataSyncHome, syncResource, PREVIEW_DIR_NAME)))[0]; +} +export function getSyncResourceFromRemotePreview(remotePreview: URI, environmentService: IEnvironmentService): SyncResource | undefined { + if (remotePreview.scheme !== USER_DATA_SYNC_SCHEME) { + return undefined; + } + remotePreview = remotePreview.with({ scheme: environmentService.userDataSyncHome.scheme }); + return ALL_SYNC_RESOURCES.filter(syncResource => isEqualOrParent(remotePreview, joinPath(environmentService.userDataSyncHome, syncResource, PREVIEW_DIR_NAME)))[0]; } diff --git a/src/vs/platform/userDataSync/common/userDataSyncIpc.ts b/src/vs/platform/userDataSync/common/userDataSyncIpc.ts index 846fbc8372..439f7e0545 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncIpc.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncIpc.ts @@ -27,9 +27,9 @@ export class UserDataSyncChannel implements IServerChannel { call(context: any, command: string, args?: any): Promise { switch (command) { - case '_getInitialData': return Promise.resolve([this.service.status, this.service.conflictsSources, this.service.lastSyncTime]); + case '_getInitialData': return Promise.resolve([this.service.status, this.service.conflicts, this.service.lastSyncTime]); case 'sync': return this.service.sync(); - case 'accept': return this.service.accept(args[0], args[1]); + case 'acceptConflict': return this.service.acceptConflict(URI.revive(args[0]), args[1]); case 'pull': return this.service.pull(); case 'stop': this.service.stop(); return Promise.resolve(); case 'reset': return this.service.reset(); diff --git a/src/vs/platform/userDataSync/common/userDataSyncService.ts b/src/vs/platform/userDataSync/common/userDataSyncService.ts index 40ccb94b47..376b4ef2ad 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncService.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IUserDataSyncService, SyncStatus, IUserDataSyncStoreService, SyncResource, IUserDataSyncLogService, IUserDataSynchroniser, UserDataSyncStoreError, UserDataSyncErrorCode, UserDataSyncError, resolveSyncResource, PREVIEW_QUERY } from 'vs/platform/userDataSync/common/userDataSync'; +import { IUserDataSyncService, SyncStatus, IUserDataSyncStoreService, SyncResource, IUserDataSyncLogService, IUserDataSynchroniser, UserDataSyncStoreError, UserDataSyncErrorCode, UserDataSyncError, resolveBackupSyncResource, SyncResourceConflicts } from 'vs/platform/userDataSync/common/userDataSync'; import { Disposable } from 'vs/base/common/lifecycle'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { Emitter, Event } from 'vs/base/common/event'; @@ -17,6 +17,7 @@ import { localize } from 'vs/nls'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import { URI } from 'vs/base/common/uri'; import { SettingsSynchroniser } from 'vs/platform/userDataSync/common/settingsSync'; +import { isEqual } from 'vs/base/common/resources'; type SyncErrorClassification = { source: { classification: 'SystemMetaData', purpose: 'FeatureInsight', isMeasurement: true }; @@ -38,10 +39,10 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ readonly onDidChangeLocal: Event; - private _conflictsSources: SyncResource[] = []; - get conflictsSources(): SyncResource[] { return this._conflictsSources; } - private _onDidChangeConflicts: Emitter = this._register(new Emitter()); - readonly onDidChangeConflicts: Event = this._onDidChangeConflicts.event; + private _conflicts: SyncResourceConflicts[] = []; + get conflicts(): SyncResourceConflicts[] { return this._conflicts; } + private _onDidChangeConflicts: Emitter = this._register(new Emitter()); + readonly onDidChangeConflicts: Event = this._onDidChangeConflicts.event; private _syncErrors: [SyncResource, UserDataSyncError][] = []; private _onSyncErrors: Emitter<[SyncResource, UserDataSyncError][]> = this._register(new Emitter<[SyncResource, UserDataSyncError][]>()); @@ -62,7 +63,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ @IInstantiationService private readonly instantiationService: IInstantiationService, @IUserDataSyncLogService private readonly logService: IUserDataSyncLogService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IStorageService private readonly storageService: IStorageService, + @IStorageService private readonly storageService: IStorageService ) { super(); this.settingsSynchroniser = this._register(this.instantiationService.createInstance(SettingsSynchroniser)); @@ -74,6 +75,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ if (this.userDataSyncStoreService.userDataSyncStore) { this._register(Event.any(...this.synchronisers.map(s => Event.map(s.onDidChangeStatus, () => undefined)))(() => this.updateStatus())); + this._register(Event.any(...this.synchronisers.map(s => Event.map(s.onDidChangeConflicts, () => undefined)))(() => this.updateConflicts())); } this._lastSyncTime = this.storageService.getNumber(LAST_SYNC_TIME_KEY, StorageScope.GLOBAL, undefined); @@ -173,23 +175,32 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ } } - async accept(source: SyncResource, content: string): Promise { + async acceptConflict(conflict: URI, content: string): Promise { await this.checkEnablement(); - const synchroniser = this.getSynchroniser(source); - await synchroniser.accept(content); + const syncResourceConflict = this.conflicts.filter(({ conflicts }) => conflicts.some(({ local, remote }) => isEqual(conflict, local) || isEqual(conflict, remote)))[0]; + if (syncResourceConflict) { + const synchroniser = this.getSynchroniser(syncResourceConflict.syncResource); + await synchroniser.acceptConflict(conflict, content); + } } async resolveContent(resource: URI): Promise { - const result = resolveSyncResource(resource); + const result = resolveBackupSyncResource(resource); if (result) { const synchronizer = this.synchronisers.filter(s => s.resource === result.resource)[0]; if (synchronizer) { - if (PREVIEW_QUERY === resource.query) { - return result.remote ? synchronizer.getRemoteContentFromPreview() : null; - } - return result.remote ? synchronizer.getRemoteContent(result.ref, resource.fragment) : synchronizer.getLocalBackupContent(result.ref, resource.fragment); + const ref = result.path !== 'latest' ? result.path : undefined; + return result.remote ? synchronizer.getRemoteContent(ref, resource.fragment) : synchronizer.getLocalBackupContent(ref, resource.fragment); } } + + for (const synchronizer of this.synchronisers) { + const content = await synchronizer.getConflictContent(resource); + if (content !== null) { + return content; + } + } + return null; } @@ -263,15 +274,19 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ } private updateStatus(): void { - const conflictsSources = this.computeConflictsSources(); - if (!equals(this._conflictsSources, conflictsSources)) { - this._conflictsSources = this.computeConflictsSources(); - this._onDidChangeConflicts.fire(conflictsSources); - } + this.updateConflicts(); const status = this.computeStatus(); this.setStatus(status); } + private updateConflicts(): void { + const conflicts = this.computeConflicts(); + if (!equals(this._conflicts, conflicts, (a, b) => a.syncResource === b.syncResource && equals(a.conflicts, b.conflicts, (a, b) => isEqual(a.local, b.local) && isEqual(a.remote, b.remote)))) { + this._conflicts = this.computeConflicts(); + this._onDidChangeConflicts.fire(conflicts); + } + } + private computeStatus(): SyncStatus { if (!this.userDataSyncStoreService.userDataSyncStore) { return SyncStatus.Uninitialized; @@ -305,8 +320,9 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ this.logService.error(`${source}: ${toErrorMessage(e)}`); } - private computeConflictsSources(): SyncResource[] { - return this.synchronisers.filter(s => s.status === SyncStatus.HasConflicts).map(s => s.resource); + private computeConflicts(): SyncResourceConflicts[] { + return this.synchronisers.filter(s => s.status === SyncStatus.HasConflicts) + .map(s => ({ syncResource: s.resource, conflicts: s.conflicts })); } getSynchroniser(source: SyncResource): IUserDataSynchroniser { diff --git a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts index 18e947bb6b..cb445f0837 100644 --- a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts +++ b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts @@ -52,9 +52,7 @@ export class UserDataSyncClient extends Disposable { const environmentService = this.instantiationService.stub(IEnvironmentService, >{ userDataSyncHome, settingsResource: joinPath(userDataDirectory, 'settings.json'), - settingsSyncPreviewResource: joinPath(userDataSyncHome, 'settings.json'), keybindingsResource: joinPath(userDataDirectory, 'keybindings.json'), - keybindingsSyncPreviewResource: joinPath(userDataSyncHome, 'keybindings.json'), argvResource: joinPath(userDataDirectory, 'argv.json'), args: {} }); diff --git a/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts b/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts index 6f5a26b6ac..14db085753 100644 --- a/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts +++ b/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts @@ -480,7 +480,7 @@ suite.skip('UserDataSyncService', () => { // {{SQL CARBON EDIT}} skip failing te await testObject.sync(); assert.deepEqual(testObject.status, SyncStatus.HasConflicts); - assert.deepEqual(testObject.conflictsSources, [SyncResource.Settings]); + assert.deepEqual(testObject.conflicts.map(({ syncResource }) => syncResource), [SyncResource.Settings]); }); test('test sync will sync other non conflicted areas', async () => { @@ -549,7 +549,7 @@ suite.skip('UserDataSyncService', () => { // {{SQL CARBON EDIT}} skip failing te await testObject.stop(); assert.deepEqual(testObject.status, SyncStatus.Idle); - assert.deepEqual(testObject.conflictsSources, []); + assert.deepEqual(testObject.conflicts, []); }); }); diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index f6a56a8e5e..392694ea72 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -1230,6 +1230,43 @@ declare module 'vscode' { //#endregion + //#region OnTypeRename: https://github.com/microsoft/vscode/issues/88424 + + /** + * The rename provider interface defines the contract between extensions and + * the live-rename feature. + */ + export interface OnTypeRenameProvider { + /** + * Provide a list of ranges that can be live renamed together. + * + * @param document The document in which the command was invoked. + * @param position The position at which the command was invoked. + * @param token A cancellation token. + * @return A list of ranges that can be live-renamed togehter. The ranges must have + * identical length and contain identical text content. The ranges cannot overlap. + */ + provideOnTypeRenameRanges(document: TextDocument, position: Position, token: CancellationToken): ProviderResult; + } + + namespace languages { + /** + * Register a rename provider that works on type. + * + * Multiple providers can be registered for a language. In that case providers are sorted + * by their [score](#languages.match) and the best-matching provider is used. Failure + * of the selected provider will cause a failure of the whole operation. + * + * @param selector A selector that defines the documents this provider is applicable to. + * @param provider An on type rename provider. + * @param stopPattern Stop on type renaming when input text matches the regular expression. Defaults to `^\s`. + * @return A [disposable](#Disposable) that unregisters this provider when being disposed. + */ + export function registerOnTypeRenameProvider(selector: DocumentSelector, provider: OnTypeRenameProvider, stopPattern?: RegExp): Disposable; + } + + //#endregion + //#region Custom editors: https://github.com/microsoft/vscode/issues/77131 // TODO: @@ -1540,6 +1577,125 @@ declare module 'vscode' { //#endregion + //#region Peng: Notebook + + export enum CellKind { + Markdown = 1, + Code = 2 + } + + export enum CellOutputKind { + Text = 1, + Error = 2, + Rich = 3 + } + + export interface CellStreamOutput { + outputKind: CellOutputKind.Text; + text: string; + } + + export interface CellErrorOutput { + outputKind: CellOutputKind.Error; + /** + * Exception Name + */ + ename: string; + /** + * Exception Value + */ + evalue: string; + /** + * Exception call stack + */ + traceback: string[]; + } + + export interface CellDisplayOutput { + outputKind: CellOutputKind.Rich; + /** + * { mime_type: value } + * + * Example: + * ```json + * { + * "outputKind": vscode.CellOutputKind.Rich, + * "data": { + * "text/html": [ + * "

Hello

" + * ], + * "text/plain": [ + * "" + * ] + * } + * } + */ + data: { [key: string]: any }; + } + + export type CellOutput = CellStreamOutput | CellErrorOutput | CellDisplayOutput; + + export interface NotebookCell { + readonly uri: Uri; + handle: number; + language: string; + cellKind: CellKind; + outputs: CellOutput[]; + getContent(): string; + } + + export interface NotebookDocument { + readonly uri: Uri; + readonly fileName: string; + readonly isDirty: boolean; + languages: string[]; + cells: NotebookCell[]; + displayOrder?: GlobPattern[]; + } + + export interface NotebookEditor { + readonly document: NotebookDocument; + viewColumn?: ViewColumn; + /** + * Create a notebook cell. The cell is not inserted into current document when created. Extensions should insert the cell into the document by [TextDocument.cells](#TextDocument.cells) + */ + createCell(content: string, language: string, type: CellKind, outputs: CellOutput[]): NotebookCell; + } + + export interface NotebookProvider { + resolveNotebook(editor: NotebookEditor): Promise; + executeCell(document: NotebookDocument, cell: NotebookCell | undefined): Promise; + save(document: NotebookDocument): Promise; + } + + export interface NotebookOutputSelector { + type: string; + subTypes?: string[]; + } + + export interface NotebookOutputRenderer { + /** + * + * @returns HTML fragment. We can probably return `CellOutput` instead of string ? + * + */ + render(document: NotebookDocument, cell: NotebookCell, output: CellOutput, mimeType: string): string; + preloads?: Uri[]; + } + + namespace window { + export function registerNotebookProvider( + notebookType: string, + provider: NotebookProvider + ): Disposable; + + export function registerNotebookOutputRenderer(type: string, outputSelector: NotebookOutputSelector, renderer: NotebookOutputRenderer): Disposable; + + export let activeNotebookDocument: NotebookDocument | undefined; + } + + //#endregion + //#region color theme access /** diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 48cc9c9c2d..acc276d30e 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -56,6 +56,7 @@ import './mainThreadWindow'; import './mainThreadWebview'; import './mainThreadWorkspace'; import './mainThreadComments'; +import './mainThreadNotebook'; // import './mainThreadTask'; {{SQL CARBON EDIT}} @anthonydresser comment out task import './mainThreadLabelService'; import './mainThreadTunnelService'; diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 589e716e55..353eba3556 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -261,6 +261,18 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha })); } + // --- on type rename + + $registerOnTypeRenameProvider(handle: number, selector: IDocumentFilterDto[], stopPattern?: IRegExpDto): void { + const revivedStopPattern = stopPattern ? MainThreadLanguageFeatures._reviveRegExp(stopPattern) : undefined; + this._registrations.set(handle, modes.OnTypeRenameProviderRegistry.register(selector, { + stopPattern: revivedStopPattern, + provideOnTypeRenameRanges: (model: ITextModel, position: EditorPosition, token: CancellationToken): Promise => { + return this._proxy.$provideOnTypeRenameRanges(handle, model.uri, position, token); + } + })); + } + // --- references $registerReferenceSupport(handle: number, selector: IDocumentFilterDto[]): void { diff --git a/src/vs/workbench/api/browser/mainThreadNotebook.ts b/src/vs/workbench/api/browser/mainThreadNotebook.ts new file mode 100644 index 0000000000..e022a0503a --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadNotebook.ts @@ -0,0 +1,255 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; +import { MainContext, MainThreadNotebookShape, NotebookExtensionDescription, IExtHostContext, ExtHostNotebookShape, ExtHostContext } from '../common/extHost.protocol'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import { INotebookService, IMainNotebookController } from 'vs/workbench/contrib/notebook/browser/notebookService'; +import { INotebookTextModel, INotebookMimeTypeSelector, NOTEBOOK_DISPLAY_ORDER, NotebookCellsSplice, NotebookCellOutputsSplice, CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; +import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; + +export class MainThreadNotebookDocument extends Disposable { + private _textModel: NotebookTextModel; + + get textModel() { + return this._textModel; + } + + constructor( + private readonly _proxy: ExtHostNotebookShape, + public handle: number, + public viewType: string, + public uri: URI + ) { + super(); + this._textModel = new NotebookTextModel(handle, viewType, uri); + } + + async deleteCell(uri: URI, index: number): Promise { + let deleteExtHostCell = await this._proxy.$deleteCell(this.viewType, uri, index); + if (deleteExtHostCell) { + this._textModel.removeCell(index); + return true; + } + + return false; + } + + dispose() { + this._textModel.dispose(); + super.dispose(); + } +} + +@extHostNamedCustomer(MainContext.MainThreadNotebook) +export class MainThreadNotebooks extends Disposable implements MainThreadNotebookShape { + private readonly _notebookProviders = new Map(); + private readonly _proxy: ExtHostNotebookShape; + + constructor( + extHostContext: IExtHostContext, + @INotebookService private _notebookService: INotebookService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostNotebook); + this.registerListeners(); + } + + registerListeners() { + this._register(this._notebookService.onDidChangeActiveEditor(e => { + this._proxy.$updateActiveEditor(e.viewType, e.uri); + })); + + let userOrder = this.configurationService.getValue('notebook.displayOrder'); + this._proxy.$acceptDisplayOrder({ + defaultOrder: NOTEBOOK_DISPLAY_ORDER, + userOrder: userOrder + }); + + this.configurationService.onDidChangeConfiguration(e => { + if (e.affectedKeys.indexOf('notebook.displayOrder') >= 0) { + let userOrder = this.configurationService.getValue('notebook.displayOrder'); + + this._proxy.$acceptDisplayOrder({ + defaultOrder: NOTEBOOK_DISPLAY_ORDER, + userOrder: userOrder + }); + } + }); + } + + async $registerNotebookRenderer(extension: NotebookExtensionDescription, type: string, selectors: INotebookMimeTypeSelector, handle: number, preloads: UriComponents[]): Promise { + this._notebookService.registerNotebookRenderer(handle, extension, type, selectors, preloads.map(uri => URI.revive(uri))); + } + + async $unregisterNotebookRenderer(handle: number): Promise { + this._notebookService.unregisterNotebookRenderer(handle); + } + + async $registerNotebookProvider(extension: NotebookExtensionDescription, viewType: string): Promise { + let controller = new MainThreadNotebookController(this._proxy, this, viewType); + this._notebookProviders.set(viewType, controller); + this._notebookService.registerNotebookController(viewType, extension, controller); + return; + } + + async $unregisterNotebookProvider(viewType: string): Promise { + this._notebookProviders.delete(viewType); + this._notebookService.unregisterNotebookProvider(viewType); + return; + } + + async $createNotebookDocument(handle: number, viewType: string, resource: UriComponents): Promise { + let controller = this._notebookProviders.get(viewType); + + if (controller) { + controller.createNotebookDocument(handle, viewType, resource); + } + + return; + } + + async $updateNotebookLanguages(viewType: string, resource: UriComponents, languages: string[]): Promise { + let controller = this._notebookProviders.get(viewType); + + if (controller) { + controller.updateLanguages(resource, languages); + } + } + + async resolveNotebook(viewType: string, uri: URI): Promise { + let handle = await this._proxy.$resolveNotebook(viewType, uri); + return handle; + } + + async $spliceNotebookCells(viewType: string, resource: UriComponents, splices: NotebookCellsSplice[], renderers: number[]): Promise { + let controller = this._notebookProviders.get(viewType); + controller?.spliceNotebookCells(resource, splices, renderers); + } + + async $spliceNotebookCellOutputs(viewType: string, resource: UriComponents, cellHandle: number, splices: NotebookCellOutputsSplice[], renderers: number[]): Promise { + let controller = this._notebookProviders.get(viewType); + controller?.spliceNotebookCellOutputs(resource, cellHandle, splices, renderers); + } + + async executeNotebook(viewType: string, uri: URI): Promise { + return this._proxy.$executeNotebook(viewType, uri, undefined); + } +} + +export class MainThreadNotebookController implements IMainNotebookController { + private _mapping: Map = new Map(); + + constructor( + private readonly _proxy: ExtHostNotebookShape, + private _mainThreadNotebook: MainThreadNotebooks, + private _viewType: string + ) { + } + + async resolveNotebook(viewType: string, uri: URI): Promise { + // TODO: resolve notebook should wait for all notebook document destory operations to finish. + let mainthreadNotebook = this._mapping.get(URI.from(uri).toString()); + + if (mainthreadNotebook) { + return mainthreadNotebook.textModel; + } + + let notebookHandle = await this._mainThreadNotebook.resolveNotebook(viewType, uri); + if (notebookHandle !== undefined) { + mainthreadNotebook = this._mapping.get(URI.from(uri).toString()); + return mainthreadNotebook?.textModel; + } + + return undefined; + } + + spliceNotebookCells(resource: UriComponents, splices: NotebookCellsSplice[], renderers: number[]): void { + let mainthreadNotebook = this._mapping.get(URI.from(resource).toString()); + mainthreadNotebook?.textModel.updateRenderers(renderers); + mainthreadNotebook?.textModel.$spliceNotebookCells(splices); + } + + spliceNotebookCellOutputs(resource: UriComponents, cellHandle: number, splices: NotebookCellOutputsSplice[], renderers: number[]): void { + let mainthreadNotebook = this._mapping.get(URI.from(resource).toString()); + mainthreadNotebook?.textModel.updateRenderers(renderers); + mainthreadNotebook?.textModel.$spliceNotebookCellOutputs(cellHandle, splices); + } + + async executeNotebook(viewType: string, uri: URI): Promise { + this._mainThreadNotebook.executeNotebook(viewType, uri); + } + + // Methods for ExtHost + async createNotebookDocument(handle: number, viewType: string, resource: UriComponents): Promise { + let document = new MainThreadNotebookDocument(this._proxy, handle, viewType, URI.revive(resource)); + this._mapping.set(URI.revive(resource).toString(), document); + } + + updateLanguages(resource: UriComponents, languages: string[]) { + let document = this._mapping.get(URI.from(resource).toString()); + document?.textModel.updateLanguages(languages); + } + + updateNotebookRenderers(resource: UriComponents, renderers: number[]): void { + let document = this._mapping.get(URI.from(resource).toString()); + document?.textModel.updateRenderers(renderers); + } + + updateNotebookActiveCell(uri: URI, cellHandle: number): void { + let mainthreadNotebook = this._mapping.get(URI.from(uri).toString()); + mainthreadNotebook?.textModel.updateActiveCell(cellHandle); + } + + async createRawCell(uri: URI, index: number, language: string, type: CellKind): Promise { + let cell = await this._proxy.$createEmptyCell(this._viewType, uri, index, language, type); + if (cell) { + let mainCell = new NotebookCellTextModel(URI.revive(cell.uri), cell.handle, cell.source, cell.language, cell.cellKind, cell.outputs); + return mainCell; + } + + return undefined; + } + + async deleteCell(uri: URI, index: number): Promise { + let mainthreadNotebook = this._mapping.get(URI.from(uri).toString()); + + if (mainthreadNotebook) { + return mainthreadNotebook.deleteCell(uri, index); + } + + return false; + } + + executeNotebookActiveCell(uri: URI): void { + let mainthreadNotebook = this._mapping.get(URI.from(uri).toString()); + + if (mainthreadNotebook && mainthreadNotebook.textModel.activeCell) { + this._proxy.$executeNotebook(this._viewType, uri, mainthreadNotebook.textModel.activeCell.handle); + } + } + + async destoryNotebookDocument(notebook: INotebookTextModel): Promise { + let document = this._mapping.get(URI.from(notebook.uri).toString()); + + if (!document) { + return; + } + + let removeFromExtHost = await this._proxy.$destoryNotebookDocument(this._viewType, notebook.uri); + if (removeFromExtHost) { + document.dispose(); + this._mapping.delete(URI.from(notebook.uri).toString()); + } + } + + async save(uri: URI): Promise { + return this._proxy.$saveNotebook(this._viewType, uri); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index b8619eb793..52b32ca05e 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -68,6 +68,7 @@ import { IURITransformerService } from 'vs/workbench/api/common/extHostUriTransf import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { IExtHostInitDataService } from 'vs/workbench/api/common/extHostInitDataService'; import { find } from 'vs/base/common/arrays'; +import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebook'; import { ExtHostTheming } from 'vs/workbench/api/common/extHostTheming'; import { IExtHostTunnelService } from 'vs/workbench/api/common/extHostTunnelService'; import { IExtHostApiDeprecationService } from 'vs/workbench/api/common/extHostApiDeprecationService'; @@ -130,7 +131,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostComment = rpcProtocol.set(ExtHostContext.ExtHostComments, new ExtHostComments(rpcProtocol, extHostCommands, extHostDocuments)); const extHostWindow = rpcProtocol.set(ExtHostContext.ExtHostWindow, new ExtHostWindow(rpcProtocol)); const extHostProgress = rpcProtocol.set(ExtHostContext.ExtHostProgress, new ExtHostProgress(rpcProtocol.getProxy(MainContext.MainThreadProgress))); - const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); + const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHosLabelService, new ExtHostLabelService(rpcProtocol)); + const extHostNotebook = rpcProtocol.set(ExtHostContext.ExtHostNotebook, new ExtHostNotebookController(rpcProtocol, extHostDocumentsAndEditors)); const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); const extHostAuthentication = rpcProtocol.set(ExtHostContext.ExtHostAuthentication, new ExtHostAuthentication(rpcProtocol)); const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands)); @@ -363,6 +365,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerDocumentHighlightProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentHighlightProvider): vscode.Disposable { return extHostLanguageFeatures.registerDocumentHighlightProvider(extension, checkSelector(selector), provider); }, + registerOnTypeRenameProvider(selector: vscode.DocumentSelector, provider: vscode.OnTypeRenameProvider, stopPattern?: RegExp): vscode.Disposable { + checkProposedApiEnabled(extension); + return extHostLanguageFeatures.registerOnTypeRenameProvider(extension, checkSelector(selector), provider, stopPattern); + }, registerReferenceProvider(selector: vscode.DocumentSelector, provider: vscode.ReferenceProvider): vscode.Disposable { return extHostLanguageFeatures.registerReferenceProvider(extension, checkSelector(selector), provider); }, @@ -597,6 +603,15 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I createInputBox(): vscode.InputBox { return extHostQuickOpen.createInputBox(extension.identifier); }, + registerNotebookProvider: (viewType: string, provider: vscode.NotebookProvider) => { + return extHostNotebook.registerNotebookProvider(extension, viewType, provider); + }, + registerNotebookOutputRenderer: (type: string, outputFilter: vscode.NotebookOutputSelector, renderer: vscode.NotebookOutputRenderer) => { + return extHostNotebook.registerNotebookOutputRenderer(type, extension, outputFilter, renderer); + }, + get activeNotebookDocument(): vscode.NotebookDocument | undefined { + return extHostNotebook.activeNotebookDocument; + }, get activeColorTheme(): vscode.ColorTheme { checkProposedApiEnabled(extension); return extHostTheming.activeColorTheme; @@ -1036,7 +1051,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I WebviewContentState: extHostTypes.WebviewContentState, UIKind: UIKind, ColorThemeKind: extHostTypes.ColorThemeKind, - TimelineItem: extHostTypes.TimelineItem + TimelineItem: extHostTypes.TimelineItem, + CellKind: extHostTypes.CellKind, + CellOutputKind: extHostTypes.CellOutputKind }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 8d38f55959..c0dc4c525f 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -51,6 +51,7 @@ import { TunnelDto } from 'vs/workbench/api/common/extHostTunnelService'; import { TunnelOptions } from 'vs/platform/remote/common/tunnel'; import { Timeline, TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor, InternalTimelineOptions } from 'vs/workbench/contrib/timeline/common/timeline'; import { revive } from 'vs/base/common/marshalling'; +import { INotebookMimeTypeSelector, IOutput, INotebookDisplayOrder } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { CallHierarchyItem } from 'vs/workbench/contrib/callHierarchy/common/callHierarchy'; import { Dto } from 'vs/base/common/types'; @@ -365,6 +366,7 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $registerHoverProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerEvaluatableExpressionProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerDocumentHighlightProvider(handle: number, selector: IDocumentFilterDto[]): void; + $registerOnTypeRenameProvider(handle: number, selector: IDocumentFilterDto[], stopPattern: IRegExpDto | undefined): void; $registerReferenceSupport(handle: number, selector: IDocumentFilterDto[]): void; $registerQuickFixSupport(handle: number, selector: IDocumentFilterDto[], metadata: ICodeActionProviderMetadataDto, displayName: string): void; $registerDocumentFormattingSupport(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier, displayName: string): void; @@ -580,6 +582,16 @@ export interface WebviewExtensionDescription { readonly location: UriComponents; } +export interface NotebookExtensionDescription { + readonly id: ExtensionIdentifier; + readonly location: UriComponents; +} + +export enum WebviewEditorCapabilities { + Editable, + SupportsHotExit, +} + export interface CustomTextEditorCapabilities { readonly supportsMove?: boolean; } @@ -639,6 +651,49 @@ export interface ExtHostWebviewsShape { $onMoveCustomEditor(handle: WebviewPanelHandle, newResource: UriComponents, viewType: string): Promise; } +export enum CellKind { + Markdown = 1, + Code = 2 +} + +export enum CellOutputKind { + Text = 1, + Error = 2, + Rich = 3 +} + +export interface ICellDto { + handle: number; + uri: UriComponents, + source: string[]; + language: string; + cellKind: CellKind; + outputs: IOutput[]; +} + +export type NotebookCellsSplice = [ + number /* start */, + number /* delete count */, + ICellDto[] +]; + +export type NotebookCellOutputsSplice = [ + number /* start */, + number /* delete count */, + IOutput[] +]; + +export interface MainThreadNotebookShape extends IDisposable { + $registerNotebookProvider(extension: NotebookExtensionDescription, viewType: string): Promise; + $unregisterNotebookProvider(viewType: string): Promise; + $registerNotebookRenderer(extension: NotebookExtensionDescription, type: string, selectors: INotebookMimeTypeSelector, handle: number, preloads: UriComponents[]): Promise; + $unregisterNotebookRenderer(handle: number): Promise; + $createNotebookDocument(handle: number, viewType: string, resource: UriComponents): Promise; + $updateNotebookLanguages(viewType: string, resource: UriComponents, languages: string[]): Promise; + $spliceNotebookCells(viewType: string, resource: UriComponents, splices: NotebookCellsSplice[], renderers: number[]): Promise; + $spliceNotebookCellOutputs(viewType: string, resource: UriComponents, cellHandle: number, splices: NotebookCellOutputsSplice[], renderers: number[]): Promise; +} + export interface MainThreadUrlsShape extends IDisposable { $registerUriHandler(handle: number, extensionId: ExtensionIdentifier): Promise; $unregisterUriHandler(handle: number): Promise; @@ -1237,6 +1292,7 @@ export interface ExtHostLanguageFeaturesShape { $provideHover(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; $provideEvaluatableExpression(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; $provideDocumentHighlights(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; + $provideOnTypeRenameRanges(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; $provideReferences(handle: number, resource: UriComponents, position: IPosition, context: modes.ReferenceContext, token: CancellationToken): Promise; $provideCodeActions(handle: number, resource: UriComponents, rangeOrSelection: IRange | ISelection, context: modes.CodeActionContext, token: CancellationToken): Promise; $releaseCodeActions(handle: number, cacheId: number): void; @@ -1466,6 +1522,17 @@ export interface ExtHostCommentsShape { $toggleReaction(commentControllerHandle: number, threadHandle: number, uri: UriComponents, comment: modes.Comment, reaction: modes.CommentReaction): Promise; } +export interface ExtHostNotebookShape { + $resolveNotebook(viewType: string, uri: UriComponents): Promise; + $executeNotebook(viewType: string, uri: UriComponents, cellHandle: number | undefined): Promise; + $createEmptyCell(viewType: string, uri: UriComponents, index: number, language: string, type: CellKind): Promise; + $deleteCell(viewType: string, uri: UriComponents, index: number): Promise; + $saveNotebook(viewType: string, uri: UriComponents): Promise; + $updateActiveEditor(viewType: string, uri: UriComponents): Promise; + $destoryNotebookDocument(viewType: string, uri: UriComponents): Promise; + $acceptDisplayOrder(displayOrder: INotebookDisplayOrder): void; +} + export interface ExtHostStorageShape { $acceptValue(shared: boolean, key: string, value: object | undefined): void; } @@ -1531,6 +1598,7 @@ export const MainContext = { MainThreadTask: createMainId('MainThreadTask'), MainThreadWindow: createMainId('MainThreadWindow'), MainThreadLabelService: createMainId('MainThreadLabelService'), + MainThreadNotebook: createMainId('MainThreadNotebook'), MainThreadTheming: createMainId('MainThreadTheming'), MainThreadTunnelService: createMainId('MainThreadTunnelService'), MainThreadTimeline: createMainId('MainThreadTimeline') @@ -1567,7 +1635,8 @@ export const ExtHostContext = { ExtHostStorage: createMainId('ExtHostStorage'), ExtHostUrls: createExtId('ExtHostUrls'), ExtHostOutputService: createMainId('ExtHostOutputService'), - ExtHostLabelService: createMainId('ExtHostLabelService'), + ExtHosLabelService: createMainId('ExtHostLabelService'), + ExtHostNotebook: createMainId('ExtHostNotebook'), ExtHostTheming: createMainId('ExtHostTheming'), ExtHostTunnelService: createMainId('ExtHostTunnelService'), ExtHostAuthentication: createMainId('ExtHostAuthentication'), diff --git a/src/vs/workbench/api/common/extHostDocumentData.ts b/src/vs/workbench/api/common/extHostDocumentData.ts index da6593cc99..470630b841 100644 --- a/src/vs/workbench/api/common/extHostDocumentData.ts +++ b/src/vs/workbench/api/common/extHostDocumentData.ts @@ -238,7 +238,7 @@ export class ExtHostDocumentData extends MirrorTextModel { } } -class ExtHostDocumentLine implements vscode.TextLine { +export class ExtHostDocumentLine implements vscode.TextLine { private readonly _line: number; private readonly _text: string; diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 7f9cb2f39e..fded0797e3 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -318,6 +318,26 @@ class DocumentHighlightAdapter { } } +class OnTypeRenameAdapter { + constructor( + private readonly _documents: ExtHostDocuments, + private readonly _provider: vscode.OnTypeRenameProvider + ) { } + + provideOnTypeRenameRanges(resource: URI, position: IPosition, token: CancellationToken): Promise { + + const doc = this._documents.getDocument(resource); + const pos = typeConvert.Position.to(position); + + return asPromise(() => this._provider.provideOnTypeRenameRanges(doc, pos, token)).then(value => { + if (Array.isArray(value)) { + return coalesce(value.map(typeConvert.Range.from)); + } + return undefined; + }); + } +} + class ReferenceAdapter { constructor( @@ -1350,7 +1370,8 @@ type Adapter = DocumentSymbolAdapter | CodeLensAdapter | DefinitionAdapter | Hov | RangeFormattingAdapter | OnTypeFormattingAdapter | NavigateTypeAdapter | RenameAdapter | SuggestAdapter | SignatureHelpAdapter | LinkProviderAdapter | ImplementationAdapter | TypeDefinitionAdapter | ColorProviderAdapter | FoldingProviderAdapter | DeclarationAdapter - | SelectionRangeAdapter | CallHierarchyAdapter | DocumentSemanticTokensAdapter | DocumentRangeSemanticTokensAdapter | EvaluatableExpressionAdapter; + | SelectionRangeAdapter | CallHierarchyAdapter | DocumentSemanticTokensAdapter | DocumentRangeSemanticTokensAdapter | EvaluatableExpressionAdapter + | OnTypeRenameAdapter; class AdapterData { constructor( @@ -1594,6 +1615,19 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF return this._withAdapter(handle, DocumentHighlightAdapter, adapter => adapter.provideDocumentHighlights(URI.revive(resource), position, token), undefined); } + // --- on type rename + + registerOnTypeRenameProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.OnTypeRenameProvider, stopPattern?: RegExp): vscode.Disposable { + const handle = this._addNewAdapter(new OnTypeRenameAdapter(this._documents, provider), extension); + const serializedStopPattern = stopPattern ? ExtHostLanguageFeatures._serializeRegExp(stopPattern) : undefined; + this._proxy.$registerOnTypeRenameProvider(handle, this._transformDocumentSelector(selector), serializedStopPattern); + return this._createDisposable(handle); + } + + $provideOnTypeRenameRanges(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise { + return this._withAdapter(handle, OnTypeRenameAdapter, adapter => adapter.provideOnTypeRenameRanges(URI.revive(resource), position, token), undefined); + } + // --- references registerReferenceProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.ReferenceProvider): vscode.Disposable { diff --git a/src/vs/workbench/api/common/extHostNotebook.ts b/src/vs/workbench/api/common/extHostNotebook.ts new file mode 100644 index 0000000000..5d632229ff --- /dev/null +++ b/src/vs/workbench/api/common/extHostNotebook.ts @@ -0,0 +1,621 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ExtHostNotebookShape, IMainContext, MainThreadNotebookShape, MainContext, ICellDto, NotebookCellsSplice, NotebookCellOutputsSplice, CellKind, CellOutputKind } from 'vs/workbench/api/common/extHost.protocol'; +import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { Disposable as VSCodeDisposable } from './extHostTypes'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import { DisposableStore, Disposable } from 'vs/base/common/lifecycle'; +import { readonly } from 'vs/base/common/errors'; +import { Emitter, Event } from 'vs/base/common/event'; +import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; +import { INotebookDisplayOrder, ITransformedDisplayOutputDto, IOrderedMimeType, IStreamOutput, IErrorOutput, mimeTypeSupportedByCore, IOutput, sortMimeTypes, diff, CellUri } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { ISplice } from 'vs/base/common/sequence'; + +export class ExtHostCell implements vscode.NotebookCell { + + public source: string[]; + private _outputs: any[]; + private _onDidChangeOutputs = new Emitter[]>(); + onDidChangeOutputs: Event[]> = this._onDidChangeOutputs.event; + private _textDocument: vscode.TextDocument | undefined; + private _initalVersion: number = -1; + private _outputMapping = new Set(); + + constructor( + readonly handle: number, + readonly uri: URI, + private _content: string, + public cellKind: CellKind, + public language: string, + outputs: any[] + ) { + this.source = this._content.split(/\r|\n|\r\n/g); + this._outputs = outputs; + } + + get outputs() { + return this._outputs; + } + + set outputs(newOutputs: vscode.CellOutput[]) { + let diffs = diff(this._outputs || [], newOutputs || [], (a) => { + return this._outputMapping.has(a); + }); + + diffs.forEach(diff => { + for (let i = diff.start; i < diff.start + diff.deleteCount; i++) { + this._outputMapping.delete(this._outputs[i]); + } + + diff.toInsert.forEach(output => { + this._outputMapping.add(output); + }); + }); + + this._outputs = newOutputs; + this._onDidChangeOutputs.fire(diffs); + } + + getContent(): string { + if (this._textDocument && this._initalVersion !== this._textDocument?.version) { + return this._textDocument.getText(); + } else { + return this.source.join('\n'); + } + } + + attachTextDocument(document: vscode.TextDocument) { + this._textDocument = document; + this._initalVersion = this._textDocument.version; + } + + detachTextDocument(document: vscode.TextDocument) { + if (this._textDocument && this._textDocument.version !== this._initalVersion) { + this.source = this._textDocument.getText().split(/\r|\n|\r\n/g); + } + + this._textDocument = undefined; + this._initalVersion = -1; + } +} + +export class ExtHostNotebookDocument extends Disposable implements vscode.NotebookDocument { + private static _handlePool: number = 0; + readonly handle = ExtHostNotebookDocument._handlePool++; + + private _cells: ExtHostCell[] = []; + + private _cellDisposableMapping = new Map(); + + get cells() { + return this._cells; + } + + set cells(newCells: ExtHostCell[]) { + let diffs = diff(this._cells, newCells, (a) => { + return this._cellDisposableMapping.has(a.handle); + }); + + diffs.forEach(diff => { + for (let i = diff.start; i < diff.start + diff.deleteCount; i++) { + this._cellDisposableMapping.get(this._cells[i].handle)?.clear(); + this._cellDisposableMapping.delete(this._cells[i].handle); + } + + diff.toInsert.forEach(cell => { + this._cellDisposableMapping.set(cell.handle, new DisposableStore()); + this._cellDisposableMapping.get(cell.handle)?.add(cell.onDidChangeOutputs((outputDiffs) => { + this.eventuallyUpdateCellOutputs(cell, outputDiffs); + })); + }); + }); + + this._cells = newCells; + this.eventuallyUpdateCells(diffs); + } + + private _languages: string[] = []; + + get languages() { + return this._languages = []; + } + + set languages(newLanguages: string[]) { + this._languages = newLanguages; + this._proxy.$updateNotebookLanguages(this.viewType, this.uri, this._languages); + } + + private _displayOrder: string[] = []; + + get displayOrder() { + return this._displayOrder; + } + + set displayOrder(newOrder: string[]) { + this._displayOrder = newOrder; + } + + constructor( + private readonly _proxy: MainThreadNotebookShape, + public viewType: string, + public uri: URI, + public renderingHandler: ExtHostNotebookOutputRenderingHandler + ) { + super(); + } + + dispose() { + super.dispose(); + this._cellDisposableMapping.forEach(cell => cell.dispose()); + } + + get fileName() { return this.uri.fsPath; } + + get isDirty() { return false; } + + eventuallyUpdateCells(diffs: ISplice[]) { + let renderers = new Set(); + let diffDtos: NotebookCellsSplice[] = []; + + diffDtos = diffs.map(diff => { + let inserts = diff.toInsert; + + let cellDtos = inserts.map(cell => { + let outputs: IOutput[] = []; + if (cell.outputs.length) { + outputs = cell.outputs.map(output => { + if (output.outputKind === CellOutputKind.Rich) { + const ret = this.transformMimeTypes(cell, output); + + if (ret.orderedMimeTypes[ret.pickedMimeTypeIndex].isResolved) { + renderers.add(ret.orderedMimeTypes[ret.pickedMimeTypeIndex].rendererId!); + } + return ret; + } else { + return output as IStreamOutput | IErrorOutput; + } + }); + } + + return { + uri: cell.uri, + handle: cell.handle, + source: cell.source, + language: cell.language, + cellKind: cell.cellKind, + outputs: outputs, + isDirty: false + }; + }); + + return [diff.start, diff.deleteCount, cellDtos]; + }); + + this._proxy.$spliceNotebookCells( + this.viewType, + this.uri, + diffDtos, + Array.from(renderers) + ); + } + + eventuallyUpdateCellOutputs(cell: ExtHostCell, diffs: ISplice[]) { + let renderers = new Set(); + let outputDtos: NotebookCellOutputsSplice[] = diffs.map(diff => { + let outputs = diff.toInsert; + + let transformedOutputs = outputs.map(output => { + if (output.outputKind === CellOutputKind.Rich) { + const ret = this.transformMimeTypes(cell, output); + + if (ret.orderedMimeTypes[ret.pickedMimeTypeIndex].isResolved) { + renderers.add(ret.orderedMimeTypes[ret.pickedMimeTypeIndex].rendererId!); + } + return ret; + } else { + return output as IStreamOutput | IErrorOutput; + } + }); + + return [diff.start, diff.deleteCount, transformedOutputs]; + }); + + this._proxy.$spliceNotebookCellOutputs(this.viewType, this.uri, cell.handle, outputDtos, Array.from(renderers)); + } + + insertCell(index: number, cell: ExtHostCell) { + this.cells.splice(index, 0, cell); + + if (!this._cellDisposableMapping.has(cell.handle)) { + this._cellDisposableMapping.set(cell.handle, new DisposableStore()); + } + + let store = this._cellDisposableMapping.get(cell.handle)!; + + store.add(cell.onDidChangeOutputs((diffs) => { + this.eventuallyUpdateCellOutputs(cell, diffs); + })); + } + + deleteCell(index: number): boolean { + if (index >= this.cells.length) { + return false; + } + + let cell = this.cells[index]; + this._cellDisposableMapping.get(cell.handle)?.dispose(); + this._cellDisposableMapping.delete(cell.handle); + + this.cells.splice(index, 1); + return true; + } + + + transformMimeTypes(cell: ExtHostCell, output: vscode.CellDisplayOutput): ITransformedDisplayOutputDto { + let mimeTypes = Object.keys(output.data); + + // TODO@rebornix, the document display order might be assigned a bit later. We need to postpone sending the outputs to the core side. + let coreDisplayOrder = this.renderingHandler.outputDisplayOrder; + const sorted = sortMimeTypes(mimeTypes, coreDisplayOrder?.userOrder || [], this._displayOrder, coreDisplayOrder?.defaultOrder || []); + + let orderMimeTypes: IOrderedMimeType[] = []; + + sorted.forEach(mimeType => { + let handlers = this.renderingHandler.findBestMatchedRenderer(mimeType); + + if (handlers.length) { + let renderedOutput = handlers[0].render(this, cell, output, mimeType); + + orderMimeTypes.push({ + mimeType: mimeType, + isResolved: true, + rendererId: handlers[0].handle, + output: renderedOutput + }); + + for (let i = 1; i < handlers.length; i++) { + orderMimeTypes.push({ + mimeType: mimeType, + isResolved: false, + rendererId: handlers[i].handle + }); + } + + if (mimeTypeSupportedByCore(mimeType)) { + orderMimeTypes.push({ + mimeType: mimeType, + isResolved: false, + rendererId: -1 + }); + } + } else { + orderMimeTypes.push({ + mimeType: mimeType, + isResolved: false + }); + } + }); + + return { + outputKind: output.outputKind, + data: output.data, + orderedMimeTypes: orderMimeTypes, + pickedMimeTypeIndex: 0 + }; + } + + getCell(cellHandle: number) { + return this.cells.find(cell => cell.handle === cellHandle); + } + + attachCellTextDocument(textDocument: vscode.TextDocument) { + let cell = this.cells.find(cell => cell.uri.toString() === textDocument.uri.toString()); + if (cell) { + cell.attachTextDocument(textDocument); + } + } + + detachCellTextDocument(textDocument: vscode.TextDocument) { + let cell = this.cells.find(cell => cell.uri.toString() === textDocument.uri.toString()); + if (cell) { + cell.detachTextDocument(textDocument); + } + } +} + +export class ExtHostNotebookEditor extends Disposable implements vscode.NotebookEditor { + private _viewColumn: vscode.ViewColumn | undefined; + private static _cellhandlePool: number = 0; + + constructor( + viewType: string, + readonly id: string, + public uri: URI, + public document: ExtHostNotebookDocument, + private _documentsAndEditors: ExtHostDocumentsAndEditors + ) { + super(); + this._register(this._documentsAndEditors.onDidAddDocuments(documents => { + for (const { document: textDocument } of documents) { + let data = CellUri.parse(textDocument.uri); + if (data) { + if (this.document.uri.toString() === data.notebook.toString()) { + document.attachCellTextDocument(textDocument); + } + } + } + })); + + this._register(this._documentsAndEditors.onDidRemoveDocuments(documents => { + for (const { document: textDocument } of documents) { + let data = CellUri.parse(textDocument.uri); + if (data) { + if (this.document.uri.toString() === data.notebook.toString()) { + document.detachCellTextDocument(textDocument); + } + } + } + })); + } + + createCell(content: string, language: string, type: CellKind, outputs: vscode.CellOutput[]): vscode.NotebookCell { + const handle = ExtHostNotebookEditor._cellhandlePool++; + const uri = CellUri.generate(this.document.uri, handle); + const cell = new ExtHostCell(handle, uri, content, type, language, outputs); + return cell; + } + + get viewColumn(): vscode.ViewColumn | undefined { + return this._viewColumn; + } + + set viewColumn(value) { + throw readonly('viewColumn'); + } +} + +export class ExtHostNotebookOutputRenderer { + private static _handlePool: number = 0; + readonly handle = ExtHostNotebookOutputRenderer._handlePool++; + + constructor( + public type: string, + public filter: vscode.NotebookOutputSelector, + public renderer: vscode.NotebookOutputRenderer + ) { + + } + + matches(mimeType: string): boolean { + if (this.filter.subTypes) { + if (this.filter.subTypes.indexOf(mimeType) >= 0) { + return true; + } + } + return false; + } + + render(document: ExtHostNotebookDocument, cell: ExtHostCell, output: vscode.CellOutput, mimeType: string): string { + let html = this.renderer.render(document, cell, output, mimeType); + + return html; + } +} + +export interface ExtHostNotebookOutputRenderingHandler { + outputDisplayOrder: INotebookDisplayOrder | undefined; + findBestMatchedRenderer(mimeType: string): ExtHostNotebookOutputRenderer[]; +} + +export class ExtHostNotebookController implements ExtHostNotebookShape, ExtHostNotebookOutputRenderingHandler { + private static _handlePool: number = 0; + + private readonly _proxy: MainThreadNotebookShape; + private readonly _notebookProviders = new Map(); + private readonly _documents = new Map(); + private readonly _editors = new Map(); + private readonly _notebookOutputRenderers = new Map(); + private _outputDisplayOrder: INotebookDisplayOrder | undefined; + + get outputDisplayOrder(): INotebookDisplayOrder | undefined { + return this._outputDisplayOrder; + } + + private _activeNotebookDocument: ExtHostNotebookDocument | undefined; + + get activeNotebookDocument() { + return this._activeNotebookDocument; + } + + constructor(mainContext: IMainContext, private _documentsAndEditors: ExtHostDocumentsAndEditors) { + this._proxy = mainContext.getProxy(MainContext.MainThreadNotebook); + } + + registerNotebookOutputRenderer( + type: string, + extension: IExtensionDescription, + filter: vscode.NotebookOutputSelector, + renderer: vscode.NotebookOutputRenderer + ): vscode.Disposable { + let extHostRenderer = new ExtHostNotebookOutputRenderer(type, filter, renderer); + this._notebookOutputRenderers.set(extHostRenderer.handle, extHostRenderer); + this._proxy.$registerNotebookRenderer({ id: extension.identifier, location: extension.extensionLocation }, type, filter, extHostRenderer.handle, renderer.preloads || []); + return new VSCodeDisposable(() => { + this._notebookOutputRenderers.delete(extHostRenderer.handle); + this._proxy.$unregisterNotebookRenderer(extHostRenderer.handle); + }); + } + + findBestMatchedRenderer(mimeType: string): ExtHostNotebookOutputRenderer[] { + let matches: ExtHostNotebookOutputRenderer[] = []; + for (let renderer of this._notebookOutputRenderers) { + if (renderer[1].matches(mimeType)) { + matches.push(renderer[1]); + } + } + + return matches; + } + + registerNotebookProvider( + extension: IExtensionDescription, + viewType: string, + provider: vscode.NotebookProvider, + ): vscode.Disposable { + + if (this._notebookProviders.has(viewType)) { + throw new Error(`Notebook provider for '${viewType}' already registered`); + } + + this._notebookProviders.set(viewType, { extension, provider }); + this._proxy.$registerNotebookProvider({ id: extension.identifier, location: extension.extensionLocation }, viewType); + return new VSCodeDisposable(() => { + this._notebookProviders.delete(viewType); + this._proxy.$unregisterNotebookProvider(viewType); + }); + } + + async $resolveNotebook(viewType: string, uri: UriComponents): Promise { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + if (!this._documents.has(URI.revive(uri).toString())) { + let document = new ExtHostNotebookDocument(this._proxy, viewType, URI.revive(uri), this); + await this._proxy.$createNotebookDocument( + document.handle, + viewType, + uri + ); + + this._documents.set(URI.revive(uri).toString(), document); + } + + let editor = new ExtHostNotebookEditor( + viewType, + `${ExtHostNotebookController._handlePool++}`, + URI.revive(uri), + this._documents.get(URI.revive(uri).toString())!, + this._documentsAndEditors + ); + + this._editors.set(URI.revive(uri).toString(), editor); + await provider.provider.resolveNotebook(editor); + // await editor.document.$updateCells(); + return editor.document.handle; + } + + return Promise.resolve(undefined); + } + + async $executeNotebook(viewType: string, uri: UriComponents, cellHandle: number | undefined): Promise { + let provider = this._notebookProviders.get(viewType); + + if (!provider) { + return; + } + + let document = this._documents.get(URI.revive(uri).toString()); + + if (!document) { + return; + } + + let cell = cellHandle !== undefined ? document.getCell(cellHandle) : undefined; + return provider.provider.executeCell(document!, cell); + } + + async $createEmptyCell(viewType: string, uri: URI, index: number, language: string, type: CellKind): Promise { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + let editor = this._editors.get(URI.revive(uri).toString()); + let document = this._documents.get(URI.revive(uri).toString()); + + let rawCell = editor?.createCell('', language, type, []) as ExtHostCell; + document?.insertCell(index, rawCell!); + + let allDocuments = this._documentsAndEditors.allDocuments(); + for (let { document: textDocument } of allDocuments) { + let data = CellUri.parse(textDocument.uri); + if (data) { + if (uri.toString() === data.notebook.toString() && textDocument.uri.toString() === rawCell.uri.toString()) { + rawCell.attachTextDocument(textDocument); + } + } + } + return { + uri: rawCell.uri, + handle: rawCell.handle, + source: rawCell.source, + language: rawCell.language, + cellKind: rawCell.cellKind, + outputs: [] + }; + } + + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + async $deleteCell(viewType: string, uri: UriComponents, index: number): Promise { + let provider = this._notebookProviders.get(viewType); + + if (!provider) { + return false; + } + + let document = this._documents.get(URI.revive(uri).toString()); + + if (document) { + return document.deleteCell(index); + } + + return false; + } + + async $saveNotebook(viewType: string, uri: UriComponents): Promise { + let provider = this._notebookProviders.get(viewType); + let document = this._documents.get(URI.revive(uri).toString()); + + if (provider && document) { + return await provider.provider.save(document); + } + + return false; + } + + async $updateActiveEditor(viewType: string, uri: UriComponents): Promise { + this._activeNotebookDocument = this._documents.get(URI.revive(uri).toString()); + } + + async $destoryNotebookDocument(viewType: string, uri: UriComponents): Promise { + let provider = this._notebookProviders.get(viewType); + + if (!provider) { + return false; + } + + let document = this._documents.get(URI.revive(uri).toString()); + + if (document) { + document.dispose(); + this._documents.delete(URI.revive(uri).toString()); + } + + let editor = this._editors.get(URI.revive(uri).toString()); + + if (editor) { + editor.dispose(); + this._editors.delete(URI.revive(uri).toString()); + } + + return true; + } + + $acceptDisplayOrder(displayOrder: INotebookDisplayOrder): void { + this._outputDisplayOrder = displayOrder; + } +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 2d85f4f5d6..0bae7255a1 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -2559,6 +2559,21 @@ export enum ColorThemeKind { //#endregion Theming +//#region Notebook + +export enum CellKind { + Markdown = 1, + Code = 2 +} + +export enum CellOutputKind { + Text = 1, + Error = 2, + Rich = 3 +} + +//#endregion + //#region Timeline @es5ClassCompat diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index 2be4b43dc9..320e6dabe3 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -12,7 +12,7 @@ import { URI } from 'vs/base/common/uri'; import { ITextFileService, stringToSnapshot } from 'vs/workbench/services/textfile/common/textfiles'; import { Schemas } from 'vs/base/common/network'; import { IEditorViewState } from 'vs/editor/common/editorCommon'; -import { DataTransfers } from 'vs/base/browser/dnd'; +import { DataTransfers, IDragAndDropData } from 'vs/base/browser/dnd'; import { DragMouseEvent } from 'vs/base/browser/mouseEvent'; import { normalizeDriveLetter } from 'vs/base/common/labels'; import { MIME_BINARY } from 'vs/base/common/mime'; @@ -21,7 +21,7 @@ import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation import { isCodeEditor } from 'vs/editor/browser/editorBrowser'; import { IEditorIdentifier, GroupIdentifier } from 'vs/workbench/common/editor'; import { IEditorService, IResourceEditorInputType } from 'vs/workbench/services/editor/common/editorService'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; import { addDisposableListener, EventType, asDomUri } from 'vs/base/browser/dom'; import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; @@ -29,6 +29,7 @@ import { withNullAsUndefined } from 'vs/base/common/types'; import { IHostService } from 'vs/workbench/services/host/browser/host'; import { isStandalone } from 'vs/base/browser/browser'; import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; +import { Emitter } from 'vs/base/common/event'; export interface IDraggedResource { resource: URI; @@ -507,3 +508,219 @@ export function containsDragType(event: DragEvent, ...dragTypesToFind: string[]) return false; } + +export interface ICompositeDragAndDrop { + drop(data: IDragAndDropData, target: string | undefined, originalEvent: DragEvent, before?: boolean): void; + onDragOver(data: IDragAndDropData, target: string | undefined, originalEvent: DragEvent): boolean; + onDragEnter(data: IDragAndDropData, target: string | undefined, originalEvent: DragEvent): boolean; +} + +export interface ICompositeDragAndDropObserverCallbacks { + onDragEnter?: (e: IDraggedCompositeData) => void; + onDragLeave?: (e: IDraggedCompositeData) => void; + onDrop?: (e: IDraggedCompositeData) => void; + onDragOver?: (e: IDraggedCompositeData) => void; + onDragStart?: (e: IDraggedCompositeData) => void; + onDragEnd?: (e: IDraggedCompositeData) => void; +} + +export class CompositeDragAndDropData implements IDragAndDropData { + constructor(private type: 'view' | 'composite', private id: string) { } + update(dataTransfer: DataTransfer): void { + // no-op + } + getData(): { + type: 'view' | 'composite'; + id: string; + } { + return { type: this.type, id: this.id }; + } +} + +export interface IDraggedCompositeData { + eventData: DragEvent; + dragAndDropData: CompositeDragAndDropData; +} + +export class DraggedCompositeIdentifier { + constructor(private _compositeId: string) { } + + get id(): string { + return this._compositeId; + } +} + +export class DraggedViewIdentifier { + constructor(private _viewId: string) { } + + get id(): string { + return this._viewId; + } +} + +export type ViewType = 'composite' | 'view'; + +export class CompositeDragAndDropObserver extends Disposable { + private transferData: LocalSelectionTransfer; + private _onDragStart = this._register(new Emitter()); + private _onDragEnd = this._register(new Emitter()); + private static _instance: CompositeDragAndDropObserver | undefined; + static get INSTANCE(): CompositeDragAndDropObserver { + if (!CompositeDragAndDropObserver._instance) { + CompositeDragAndDropObserver._instance = new CompositeDragAndDropObserver(); + } + return CompositeDragAndDropObserver._instance; + } + private constructor() { + super(); + this.transferData = LocalSelectionTransfer.getInstance(); + } + private readDragData(type: ViewType): CompositeDragAndDropData | undefined { + if (this.transferData.hasData(type === 'view' ? DraggedViewIdentifier.prototype : DraggedCompositeIdentifier.prototype)) { + const data = this.transferData.getData(type === 'view' ? DraggedViewIdentifier.prototype : DraggedCompositeIdentifier.prototype); + if (data && data[0]) { + return new CompositeDragAndDropData(type, data[0].id); + } + } + return undefined; + } + private writeDragData(id: string, type: ViewType): void { + this.transferData.setData([type === 'view' ? new DraggedViewIdentifier(id) : new DraggedCompositeIdentifier(id)], type === 'view' ? DraggedViewIdentifier.prototype : DraggedCompositeIdentifier.prototype); + } + registerTarget(element: HTMLElement, callbacks: ICompositeDragAndDropObserverCallbacks): IDisposable { + const disposableStore = new DisposableStore(); + disposableStore.add(new DragAndDropObserver(element, { + onDragEnd: e => { + // no-op + }, + onDragEnter: e => { + e.preventDefault(); + if (callbacks.onDragEnter) { + const data = this.readDragData('composite') || this.readDragData('view'); + if (data) { + callbacks.onDragEnter({ eventData: e, dragAndDropData: data! }); + } + } + }, + onDragLeave: e => { + const data = this.readDragData('composite') || this.readDragData('view'); + if (callbacks.onDragLeave && data) { + callbacks.onDragLeave({ eventData: e, dragAndDropData: data! }); + } + }, + onDrop: e => { + if (callbacks.onDrop) { + const data = this.readDragData('composite') || this.readDragData('view'); + if (!data) { + return; + } + + callbacks.onDrop({ eventData: e, dragAndDropData: data! }); + + // Fire drag event in case drop handler destroys the dragged element + this._onDragEnd.fire({ eventData: e, dragAndDropData: data! }); + } + }, + onDragOver: e => { + e.preventDefault(); + if (callbacks.onDragOver) { + const data = this.readDragData('composite') || this.readDragData('view'); + if (!data) { + return; + } + + callbacks.onDragOver({ eventData: e, dragAndDropData: data! }); + } + } + })); + if (callbacks.onDragStart) { + this._onDragStart.event(e => { + callbacks.onDragStart!(e); + }, this, disposableStore); + } + if (callbacks.onDragEnd) { + this._onDragEnd.event(e => { + callbacks.onDragEnd!(e); + }); + } + return this._register(disposableStore); + } + registerDraggable(element: HTMLElement, type: ViewType, id: string, callbacks: ICompositeDragAndDropObserverCallbacks): IDisposable { + element.draggable = true; + const disposableStore = new DisposableStore(); + disposableStore.add(addDisposableListener(element, EventType.DRAG_START, e => { + this.writeDragData(id, type); + this._onDragStart.fire({ eventData: e, dragAndDropData: this.readDragData(type)! }); + })); + disposableStore.add(new DragAndDropObserver(element, { + onDragEnd: e => { + const data = this.readDragData(type); + if (data && data.getData().id === id) { + this.transferData.clearData(type === 'view' ? DraggedViewIdentifier.prototype : DraggedCompositeIdentifier.prototype); + } + + if (!data) { + return; + } + + this._onDragEnd.fire({ eventData: e, dragAndDropData: data! }); + }, + onDragEnter: e => { + if (callbacks.onDragEnter) { + const data = this.readDragData('composite') || this.readDragData('view'); + if (!data) { + return; + } + + if (data) { + callbacks.onDragEnter({ eventData: e, dragAndDropData: data! }); + } + } + }, + onDragLeave: e => { + const data = this.readDragData('composite') || this.readDragData('view'); + if (!data) { + return; + } + + if (callbacks.onDragLeave) { + callbacks.onDragLeave({ eventData: e, dragAndDropData: data! }); + } + }, + onDrop: e => { + if (callbacks.onDrop) { + const data = this.readDragData('composite') || this.readDragData('view'); + + if (!data) { + return; + } + callbacks.onDrop({ eventData: e, dragAndDropData: data! }); + + // Fire drag event in case drop handler destroys the dragged element + this._onDragEnd.fire({ eventData: e, dragAndDropData: data! }); + } + }, + onDragOver: e => { + if (callbacks.onDragOver) { + const data = this.readDragData('composite') || this.readDragData('view'); + if (!data) { + return; + } + + callbacks.onDragOver({ eventData: e, dragAndDropData: data! }); + } + } + })); + if (callbacks.onDragStart) { + this._onDragStart.event(e => { + callbacks.onDragStart!(e); + }, this, disposableStore); + } + if (callbacks.onDragEnd) { + this._onDragEnd.event(e => { + callbacks.onDragEnd!(e); + }); + } + return this._register(disposableStore); + } +} diff --git a/src/vs/workbench/browser/media/part.css b/src/vs/workbench/browser/media/part.css index c26d2ec77e..ad3f7bfa3d 100644 --- a/src/vs/workbench/browser/media/part.css +++ b/src/vs/workbench/browser/media/part.css @@ -8,6 +8,19 @@ overflow: hidden; } +.monaco-workbench .part > .drop-block-overlay { + visibility: hidden; /* use visibility to ensure transitions */ + transition-property: opacity; + transition-timing-function: linear; + transition-duration: 250ms; + width: 100%; + height: 100%; + position: absolute; + top: 0; + opacity: 0; + pointer-events: none; +} + .monaco-workbench .part > .title { display: none; /* Parts have to opt in to show title area */ } diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index ee5d31b6cc..501ecea840 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -130,8 +130,7 @@ export class ActivitybarPart extends Part implements IActivityBarService { hidePart: () => this.layoutService.setSideBarHidden(true), dndHandler: new CompositeDragAndDrop(this.viewDescriptorService, ViewContainerLocation.Sidebar, (id: string, focus?: boolean) => this.viewletService.openViewlet(id, focus), - (from: string, to: string) => this.compositeBar.move(from, to), - () => this.getPinnedViewletIds() + (from: string, to: string, before?: boolean) => this.compositeBar.move(from, to, before) ), compositeSize: 50, colors: (theme: IColorTheme) => this.getActivitybarItemColors(theme), @@ -337,6 +336,7 @@ export class ActivitybarPart extends Part implements IActivityBarService { container.style.borderLeftWidth = borderColor && !isPositionLeft ? '1px' : ''; container.style.borderLeftStyle = borderColor && !isPositionLeft ? 'solid' : ''; container.style.borderLeftColor = !isPositionLeft ? borderColor : ''; + // container.style.outlineColor = this.getColor(ACTIVITY_BAR_DRAG_AND_DROP_BACKGROUND) ?? ''; } private getActivitybarItemColors(theme: IColorTheme): ICompositeBarColors { diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index cb3b99ad08..187a1a4358 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -9,6 +9,42 @@ margin-bottom: 4px; } +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item::before, +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item::after { + position: absolute; + content: ''; + width: 48px; + height: 2px; + display: block; + background-color: var(--insert-border-color); + opacity: 0; + transition-property: opacity; + transition-duration: 0ms; + transition-delay: 100ms; +} + +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item::before { + margin-top: -3px; + margin-bottom: 1px; +} + +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item::after { + margin-top: 1px; + margin-bottom: -3px; +} + +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.top::before, +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.top::after, +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.bottom::before, +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.bottom::after { + transition-delay: 0s; +} + +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.top::before, +.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.bottom::after { + opacity: 1; +} + .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label { position: relative; z-index: 1; diff --git a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css index e02a939722..fb26f74550 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css @@ -7,6 +7,12 @@ width: 48px; } +.monaco-workbench .part > .drop-block-overlay.visible { + visibility: visible; + backdrop-filter: brightness(97%) blur(2px); + opacity: 1; +} + .monaco-workbench .activitybar > .content { height: 100%; display: flex; diff --git a/src/vs/workbench/browser/parts/compositeBar.ts b/src/vs/workbench/browser/parts/compositeBar.ts index 74d07dbd70..f83c8222d1 100644 --- a/src/vs/workbench/browser/parts/compositeBar.ts +++ b/src/vs/workbench/browser/parts/compositeBar.ts @@ -11,21 +11,19 @@ import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { IBadge } from 'vs/workbench/services/activity/common/activity'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ActionBar, ActionsOrientation, Separator } from 'vs/base/browser/ui/actionbar/actionbar'; -import { CompositeActionViewItem, CompositeOverflowActivityAction, ICompositeActivity, CompositeOverflowActivityActionViewItem, ActivityAction, ICompositeBar, ICompositeBarColors, DraggedCompositeIdentifier } from 'vs/workbench/browser/parts/compositeBarActions'; +import { CompositeActionViewItem, CompositeOverflowActivityAction, ICompositeActivity, CompositeOverflowActivityActionViewItem, ActivityAction, ICompositeBar, ICompositeBarColors } from 'vs/workbench/browser/parts/compositeBarActions'; import { Dimension, $, addDisposableListener, EventType, EventHelper } from 'vs/base/browser/dom'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { Widget } from 'vs/base/browser/ui/widget'; import { isUndefinedOrNull } from 'vs/base/common/types'; -import { LocalSelectionTransfer, DragAndDropObserver } from 'vs/workbench/browser/dnd'; import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeService'; import { Emitter } from 'vs/base/common/event'; -import { DraggedViewIdentifier } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { Registry } from 'vs/platform/registry/common/platform'; import { IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, IViewDescriptorService } from 'vs/workbench/common/views'; -import { ICompositeDragAndDrop, CompositeDragAndDropData } from 'vs/base/parts/composite/browser/compositeDnd'; import { IPaneComposite } from 'vs/workbench/common/panecomposite'; import { IComposite } from 'vs/workbench/common/composite'; +import { CompositeDragAndDropData, CompositeDragAndDropObserver, IDraggedCompositeData, ICompositeDragAndDrop } from 'vs/workbench/browser/dnd'; export interface ICompositeBarItem { id: string; @@ -41,30 +39,38 @@ export class CompositeDragAndDrop implements ICompositeDragAndDrop { private viewDescriptorService: IViewDescriptorService, private targetContainerLocation: ViewContainerLocation, private openComposite: (id: string, focus?: boolean) => Promise, - private moveComposite: (from: string, to: string) => void, - private getVisibleCompositeIds: () => string[] + private moveComposite: (from: string, to: string, before?: boolean) => void, ) { } - drop(data: CompositeDragAndDropData, targetCompositeId: string | undefined, originalEvent: DragEvent): void { + drop(data: CompositeDragAndDropData, targetCompositeId: string | undefined, originalEvent: DragEvent, before?: boolean): void { const dragData = data.getData(); const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); if (dragData.type === 'composite') { const currentContainer = viewContainerRegistry.get(dragData.id)!; const currentLocation = viewContainerRegistry.getViewContainerLocation(currentContainer); + + // Inserting a composite between composites if (targetCompositeId) { - if (currentLocation !== this.targetContainerLocation && this.targetContainerLocation !== ViewContainerLocation.Panel) { - const destinationContainer = viewContainerRegistry.get(targetCompositeId); - if (destinationContainer && !destinationContainer.rejectAddedViews) { - const viewsToMove = this.viewDescriptorService.getViewDescriptors(currentContainer)!.allViewDescriptors.filter(vd => vd.canMoveView); - this.viewDescriptorService.moveViewsToContainer(viewsToMove, destinationContainer); - this.openComposite(targetCompositeId, true).then(composite => { + // ... on the same composite bar + if (currentLocation === this.targetContainerLocation) { + this.moveComposite(dragData.id, targetCompositeId, before); + } + // ... on a different composite bar + else { + const viewsToMove = this.viewDescriptorService.getViewDescriptors(currentContainer)!.allViewDescriptors.filter(vd => vd.canMoveView); + if (viewsToMove.length === 1) { + this.viewDescriptorService.moveViewToLocation(viewsToMove[0], this.targetContainerLocation); + + const newContainer = this.viewDescriptorService.getViewContainer(viewsToMove[0].id)!; + + this.moveComposite(newContainer.id, targetCompositeId, before); + + this.openComposite(newContainer.id, true).then(composite => { if (composite && viewsToMove.length === 1) { composite.openView(viewsToMove[0].id, true); } }); } - } else { - this.moveComposite(dragData.id, targetCompositeId); } } else { const draggedViews = this.viewDescriptorService.getViewDescriptors(currentContainer).allViewDescriptors; @@ -76,38 +82,24 @@ export class CompositeDragAndDrop implements ICompositeDragAndDrop { } if (dragData.type === 'view') { - const viewDescriptor = this.viewDescriptorService.getViewDescriptor(dragData.id); - if (viewDescriptor && viewDescriptor.canMoveView) { - if (targetCompositeId) { - const destinationContainer = viewContainerRegistry.get(targetCompositeId); - if (destinationContainer && !destinationContainer.rejectAddedViews) { - if (this.targetContainerLocation === ViewContainerLocation.Sidebar || this.targetContainerLocation === ViewContainerLocation.Panel) { - this.viewDescriptorService.moveViewsToContainer([viewDescriptor], destinationContainer); - this.openComposite(targetCompositeId, true).then(composite => { - if (composite) { - composite.openView(viewDescriptor.id, true); - } - }); - } else { - this.viewDescriptorService.moveViewToLocation(viewDescriptor, this.targetContainerLocation); - this.moveComposite(this.viewDescriptorService.getViewContainer(viewDescriptor.id)!.id, targetCompositeId); - } - } - } else { - this.viewDescriptorService.moveViewToLocation(viewDescriptor, this.targetContainerLocation); - const newCompositeId = this.viewDescriptorService.getViewContainer(dragData.id)!.id; - const visibleItems = this.getVisibleCompositeIds(); - const targetId = visibleItems.length ? visibleItems[visibleItems.length - 1] : undefined; - if (targetId && targetId !== newCompositeId) { - this.moveComposite(newCompositeId, targetId); - } + if (targetCompositeId) { + const viewToMove = this.viewDescriptorService.getViewDescriptor(dragData.id)!; - this.openComposite(newCompositeId, true).then(composite => { + if (viewToMove && viewToMove.canMoveView) { + this.viewDescriptorService.moveViewToLocation(viewToMove, this.targetContainerLocation); + + const newContainer = this.viewDescriptorService.getViewContainer(viewToMove.id)!; + + this.moveComposite(newContainer.id, targetCompositeId, before); + + this.openComposite(newContainer.id, true).then(composite => { if (composite) { - composite.openView(viewDescriptor.id, true); + composite.openView(viewToMove.id, true); } }); } + } else { + } } } @@ -129,41 +121,21 @@ export class CompositeDragAndDrop implements ICompositeDragAndDrop { const currentContainer = viewContainerRegistry.get(dragData.id)!; const currentLocation = viewContainerRegistry.getViewContainerLocation(currentContainer); - // ... to the same location + // ... to the same composite location if (currentLocation === this.targetContainerLocation) { return true; } - // ... across view containers but without a destination composite - if (!targetCompositeId) { - const draggedViews = this.viewDescriptorService.getViewDescriptors(currentContainer)!.allViewDescriptors; - if (draggedViews.some(vd => !vd.canMoveView)) { - return false; - } - - if (draggedViews.length !== 1) { - return false; - } - - const defaultLocation = viewContainerRegistry.getViewContainerLocation(this.viewDescriptorService.getDefaultContainer(draggedViews[0].id)!); - if (this.targetContainerLocation === ViewContainerLocation.Sidebar && this.targetContainerLocation !== defaultLocation) { - return false; - } - - return true; - } - - // ... from panel to the sidebar - if (this.targetContainerLocation === ViewContainerLocation.Sidebar) { - const destinationContainer = viewContainerRegistry.get(targetCompositeId); - return !!destinationContainer && - !destinationContainer.rejectAddedViews && - this.viewDescriptorService.getViewDescriptors(currentContainer)!.allViewDescriptors.some(vd => vd.canMoveView); - } - // ... from sidebar to the panel - else { + // ... to another composite location + const draggedViews = this.viewDescriptorService.getViewDescriptors(currentContainer)!.allViewDescriptors; + if (draggedViews.length !== 1) { return false; } + + // ... single view + const defaultContainer = this.viewDescriptorService.getDefaultContainer(draggedViews[0].id); + const canMoveToDefault = !!defaultContainer && this.viewDescriptorService.getViewContainerLocation(defaultContainer) === this.targetContainerLocation; + return !!draggedViews[0].canMoveView && (!!draggedViews[0].containerIcon || canMoveToDefault || this.targetContainerLocation === ViewContainerLocation.Panel); } else { // Dragging an individual view const viewDescriptor = this.viewDescriptorService.getViewDescriptor(dragData.id); @@ -174,13 +146,7 @@ export class CompositeDragAndDrop implements ICompositeDragAndDrop { } // ... to create a view container - if (!targetCompositeId) { - return this.targetContainerLocation === ViewContainerLocation.Panel; - } - - // ... into a destination - const destinationContainer = viewContainerRegistry.get(targetCompositeId); - return !!destinationContainer && !destinationContainer.rejectAddedViews; + return this.targetContainerLocation === ViewContainerLocation.Panel || !!viewDescriptor.containerIcon; } } } @@ -215,8 +181,6 @@ export class CompositeBar extends Widget implements ICompositeBar { private visibleComposites: string[]; private compositeSizeInBar: Map; - private compositeTransfer: LocalSelectionTransfer; - private readonly _onDidChange: Emitter = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; @@ -232,7 +196,6 @@ export class CompositeBar extends Widget implements ICompositeBar { this.model = new CompositeBarModel(items, options); this.visibleComposites = []; this.compositeSizeInBar = new Map(); - this.compositeTransfer = LocalSelectionTransfer.getInstance(); this.computeSizes(this.model.visibleItems); } @@ -278,100 +241,25 @@ export class CompositeBar extends Widget implements ICompositeBar { // Contextmenu for composites this._register(addDisposableListener(parent, EventType.CONTEXT_MENU, e => this.showContextMenu(e))); + // Register a drop target on the whole bar to prevent forbidden feedback + this._register(CompositeDragAndDropObserver.INSTANCE.registerTarget(parent, {})); + // Allow to drop at the end to move composites to the end - this._register(new DragAndDropObserver(excessDiv, { - onDragOver: (e: DragEvent) => { - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype)) { - EventHelper.stop(e, true); - - const data = this.compositeTransfer.getData(DraggedCompositeIdentifier.prototype); - if (Array.isArray(data)) { - const draggedCompositeId = data[0].id; - - // Check if drop is allowed - if (e.dataTransfer && !this.options.dndHandler.onDragOver(new CompositeDragAndDropData('composite', draggedCompositeId), undefined, e)) { - e.dataTransfer.dropEffect = 'none'; - } - } - } - - if (this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - EventHelper.stop(e, true); - - const data = this.compositeTransfer.getData(DraggedViewIdentifier.prototype); - if (Array.isArray(data)) { - const draggedViewId = data[0].id; - - // Check if drop is allowed - if (e.dataTransfer && !this.options.dndHandler.onDragOver(new CompositeDragAndDropData('view', draggedViewId), undefined, e)) { - e.dataTransfer.dropEffect = 'none'; - } - } - } + this._register(CompositeDragAndDropObserver.INSTANCE.registerTarget(excessDiv, { + onDragEnter: (e: IDraggedCompositeData) => { + const pinnedItems = this.getPinnedComposites(); + const validDropTarget = this.options.dndHandler.onDragEnter(e.dragAndDropData, pinnedItems[pinnedItems.length - 1].id, e.eventData); + this.updateFromDragging(excessDiv, validDropTarget); }, - onDragEnter: (e: DragEvent) => { - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype)) { - EventHelper.stop(e, true); - - const data = this.compositeTransfer.getData(DraggedCompositeIdentifier.prototype); - if (Array.isArray(data)) { - const draggedCompositeId = data[0].id; - - // Check if drop is allowed - const validDropTarget = this.options.dndHandler.onDragEnter(new CompositeDragAndDropData('composite', draggedCompositeId), undefined, e); - this.updateFromDragging(excessDiv, validDropTarget); - } - } - - if (this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - EventHelper.stop(e, true); - - const data = this.compositeTransfer.getData(DraggedViewIdentifier.prototype); - if (Array.isArray(data)) { - const draggedViewId = data[0].id; - - // Check if drop is allowed - const validDropTarget = this.options.dndHandler.onDragEnter(new CompositeDragAndDropData('view', draggedViewId), undefined, e); - this.updateFromDragging(excessDiv, validDropTarget); - } - } - }, - - onDragLeave: (e: DragEvent) => { - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype) || - this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - this.updateFromDragging(excessDiv, false); - } - }, - onDragEnd: (e: DragEvent) => { - // no-op, will not be called - }, - onDrop: (e: DragEvent) => { - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype)) { - EventHelper.stop(e, true); - - const data = this.compositeTransfer.getData(DraggedCompositeIdentifier.prototype); - if (Array.isArray(data)) { - const draggedCompositeId = data[0].id; - this.compositeTransfer.clearData(DraggedCompositeIdentifier.prototype); - - this.options.dndHandler.drop(new CompositeDragAndDropData('composite', draggedCompositeId), undefined, e); - this.updateFromDragging(excessDiv, false); - } - } - - if (this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - const data = this.compositeTransfer.getData(DraggedViewIdentifier.prototype); - if (Array.isArray(data)) { - const draggedViewId = data[0].id; - this.compositeTransfer.clearData(DraggedViewIdentifier.prototype); - - this.options.dndHandler.drop(new CompositeDragAndDropData('view', draggedViewId), undefined, e); - this.updateFromDragging(excessDiv, false); - } - } + onDragLeave: (e: IDraggedCompositeData) => { + this.updateFromDragging(excessDiv, false); }, + onDrop: (e: IDraggedCompositeData) => { + const pinnedItems = this.getPinnedComposites(); + this.options.dndHandler.drop(e.dragAndDropData, pinnedItems[pinnedItems.length - 1].id, e.eventData, false); + this.updateFromDragging(excessDiv, false); + } })); return actionBarDiv; @@ -519,10 +407,34 @@ export class CompositeBar extends Widget implements ICompositeBar { return item?.pinned; } - move(compositeId: string, toCompositeId: string): void { - if (this.model.move(compositeId, toCompositeId)) { - // timeout helps to prevent artifacts from showing up - setTimeout(() => this.updateCompositeSwitcher(), 0); + move(compositeId: string, toCompositeId: string, before?: boolean): void { + + if (before !== undefined) { + const fromIndex = this.model.items.findIndex(c => c.id === compositeId); + let toIndex = this.model.items.findIndex(c => c.id === toCompositeId); + + if (fromIndex >= 0 && toIndex >= 0) { + if (!before && fromIndex > toIndex) { + toIndex++; + } + + if (before && fromIndex < toIndex) { + toIndex--; + } + + if (toIndex < this.model.items.length && toIndex >= 0 && toIndex !== fromIndex) { + if (this.model.move(this.model.items[fromIndex].id, this.model.items[toIndex].id)) { + // timeout helps to prevent artifacts from showing up + setTimeout(() => this.updateCompositeSwitcher(), 0); + } + } + } + + } else { + if (this.model.move(compositeId, toCompositeId)) { + // timeout helps to prevent artifacts from showing up + setTimeout(() => this.updateCompositeSwitcher(), 0); + } } } diff --git a/src/vs/workbench/browser/parts/compositeBarActions.ts b/src/vs/workbench/browser/parts/compositeBarActions.ts index c7ca75eb87..9cbc6575c1 100644 --- a/src/vs/workbench/browser/parts/compositeBarActions.ts +++ b/src/vs/workbench/browser/parts/compositeBarActions.ts @@ -18,10 +18,8 @@ import { DelayedDragHandler } from 'vs/base/browser/dnd'; import { IActivity } from 'vs/workbench/common/activity'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { Emitter } from 'vs/base/common/event'; -import { DragAndDropObserver, LocalSelectionTransfer } from 'vs/workbench/browser/dnd'; +import { LocalSelectionTransfer, DraggedCompositeIdentifier, DraggedViewIdentifier, CompositeDragAndDropObserver, ICompositeDragAndDrop } from 'vs/workbench/browser/dnd'; import { Color } from 'vs/base/common/color'; -import { DraggedViewIdentifier } from 'vs/workbench/browser/parts/views/viewPaneContainer'; -import { ICompositeDragAndDrop, CompositeDragAndDropData } from 'vs/base/parts/composite/browser/compositeDnd'; export interface ICompositeActivity { badge: IBadge; @@ -167,11 +165,15 @@ export class ActivityActionViewItem extends BaseActionViewItem { // Apply foreground color to activity bar items provided with codicons this.label.style.color = foreground ? foreground.toString() : ''; } + + const dragColor = colors.activeBackgroundColor || colors.activeForegroundColor; + this.container.style.setProperty('--insert-border-color', dragColor ? dragColor.toString() : ''); } else { const foreground = this._action.checked ? colors.activeForegroundColor : colors.inactiveForegroundColor; const borderBottomColor = this._action.checked ? colors.activeBorderBottomColor : null; this.label.style.color = foreground ? foreground.toString() : ''; this.label.style.borderBottomColor = borderBottomColor ? borderBottomColor.toString() : ''; + this.container.style.setProperty('--insert-border-color', colors.activeForegroundColor ? colors.activeForegroundColor.toString() : ''); } } @@ -445,14 +447,6 @@ class ManageExtensionAction extends Action { } } -export class DraggedCompositeIdentifier { - constructor(private _compositeId: string) { } - - get id(): string { - return this._compositeId; - } -} - export class CompositeActionViewItem extends ActivityActionViewItem { private static manageExtensionAction: ManageExtensionAction; @@ -522,105 +516,38 @@ export class CompositeActionViewItem extends ActivityActionViewItem { this.showContextMenu(container); })); + let insertDropBefore: boolean | undefined = undefined; // Allow to drag - this._register(dom.addDisposableListener(this.container, dom.EventType.DRAG_START, (e: DragEvent) => { - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - } - - // Registe as dragged to local transfer - this.compositeTransfer.setData([new DraggedCompositeIdentifier(this.activity.id)], DraggedCompositeIdentifier.prototype); - - // Trigger the action even on drag start to prevent clicks from failing that started a drag - if (!this.getAction().checked) { - this.getAction().run(); - } - })); - - this._register(new DragAndDropObserver(this.container, { - onDragEnter: e => { - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype)) { - const data = this.compositeTransfer.getData(DraggedCompositeIdentifier.prototype); - if (Array.isArray(data) && data[0].id !== this.activity.id) { - const validDropTarget = this.dndHandler.onDragEnter(new CompositeDragAndDropData('composite', data[0].id), this.activity.id, e); - this.updateFromDragging(container, validDropTarget); - } - } - - if (this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - const data = this.compositeTransfer.getData(DraggedViewIdentifier.prototype); - if (Array.isArray(data) && data[0].id !== this.activity.id) { - const validDropTarget = this.dndHandler.onDragEnter(new CompositeDragAndDropData('view', data[0].id), this.activity.id, e); - this.updateFromDragging(container, validDropTarget); - } - } - }, - + this._register(CompositeDragAndDropObserver.INSTANCE.registerDraggable(this.container, 'composite', this.activity.id, { onDragOver: e => { - dom.EventHelper.stop(e, true); - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype)) { - const data = this.compositeTransfer.getData(DraggedCompositeIdentifier.prototype); - if (Array.isArray(data)) { - const draggedCompositeId = data[0].id; - if (draggedCompositeId !== this.activity.id) { - if (e.dataTransfer && !this.dndHandler.onDragOver(new CompositeDragAndDropData('composite', draggedCompositeId), this.activity.id, e)) { - e.dataTransfer.dropEffect = 'none'; - } - } - } - } - - if (this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - const data = this.compositeTransfer.getData(DraggedViewIdentifier.prototype); - if (Array.isArray(data)) { - const draggedViewId = data[0].id; - if (e.dataTransfer && !this.dndHandler.onDragOver(new CompositeDragAndDropData('view', draggedViewId), this.activity.id, e)) { - e.dataTransfer.dropEffect = 'none'; - } - } - } + const isValidMove = e.dragAndDropData.getData().id !== this.activity.id && this.dndHandler.onDragOver(e.dragAndDropData, this.activity.id, e.eventData); + insertDropBefore = this.updateFromDragging(container, isValidMove, e.eventData); }, onDragLeave: e => { - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype) || - this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - this.updateFromDragging(container, false); - } + insertDropBefore = this.updateFromDragging(container, false, e.eventData); }, onDragEnd: e => { - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype)) { - this.updateFromDragging(container, false); - - this.compositeTransfer.clearData(DraggedCompositeIdentifier.prototype); - } + insertDropBefore = this.updateFromDragging(container, false, e.eventData); }, onDrop: e => { - dom.EventHelper.stop(e, true); - - if (this.compositeTransfer.hasData(DraggedCompositeIdentifier.prototype)) { - const data = this.compositeTransfer.getData(DraggedCompositeIdentifier.prototype); - if (Array.isArray(data)) { - const draggedCompositeId = data[0].id; - if (draggedCompositeId !== this.activity.id) { - this.updateFromDragging(container, false); - this.compositeTransfer.clearData(DraggedCompositeIdentifier.prototype); - - this.dndHandler.drop(new CompositeDragAndDropData('composite', draggedCompositeId), this.activity.id, e); - } - } + this.dndHandler.drop(e.dragAndDropData, this.activity.id, e.eventData, !!insertDropBefore); + insertDropBefore = this.updateFromDragging(container, false, e.eventData); + }, + onDragStart: e => { + if (e.dragAndDropData.getData().id !== this.activity.id) { + return; } - if (this.compositeTransfer.hasData(DraggedViewIdentifier.prototype)) { - const data = this.compositeTransfer.getData(DraggedViewIdentifier.prototype); - if (Array.isArray(data)) { - const draggedViewId = data[0].id; - this.updateFromDragging(container, false); - this.compositeTransfer.clearData(DraggedViewIdentifier.prototype); + if (e.eventData.dataTransfer) { + e.eventData.dataTransfer.effectAllowed = 'move'; + } - this.dndHandler.drop(new CompositeDragAndDropData('view', draggedViewId), this.activity.id, e); - } + // Trigger the action even on drag start to prevent clicks from failing that started a drag + if (!this.getAction().checked) { + this.getAction().run(); } } })); @@ -637,11 +564,42 @@ export class CompositeActionViewItem extends ActivityActionViewItem { this.updateStyles(); } - private updateFromDragging(element: HTMLElement, isDragging: boolean): void { - const theme = this.themeService.getColorTheme(); - const dragBackground = this.options.colors(theme).dragAndDropBackground; + private updateFromDragging(element: HTMLElement, showFeedback: boolean, event: DragEvent): boolean | undefined { + const rect = element.getBoundingClientRect(); + const posX = event.clientX; + const posY = event.clientY; + const height = rect.bottom - rect.top; + const width = rect.right - rect.left; - element.style.backgroundColor = isDragging && dragBackground ? dragBackground.toString() : ''; + const forceTop = posY <= rect.top + height * 0.4; + const forceBottom = posY > rect.bottom - height * 0.4; + const preferTop = posY <= rect.top + height * 0.5; + + const forceLeft = posX <= rect.left + width * 0.4; + const forceRight = posX > rect.right - width * 0.4; + const preferLeft = posX <= rect.left + width * 0.5; + + const classes = element.classList; + const lastClasses = { + vertical: classes.contains('top') ? 'top' : (classes.contains('bottom') ? 'bottom' : undefined), + horizontal: classes.contains('left') ? 'left' : (classes.contains('right') ? 'right' : undefined) + }; + + const top = forceTop || (preferTop && !lastClasses.vertical) || (!forceBottom && lastClasses.vertical === 'top'); + const bottom = forceBottom || (!preferTop && !lastClasses.vertical) || (!forceTop && lastClasses.vertical === 'bottom'); + const left = forceLeft || (preferLeft && !lastClasses.horizontal) || (!forceRight && lastClasses.horizontal === 'left'); + const right = forceRight || (!preferLeft && !lastClasses.horizontal) || (!forceLeft && lastClasses.horizontal === 'right'); + + dom.toggleClass(element, 'top', showFeedback && top); + dom.toggleClass(element, 'bottom', showFeedback && bottom); + dom.toggleClass(element, 'left', showFeedback && left); + dom.toggleClass(element, 'right', showFeedback && right); + + if (!showFeedback) { + return undefined; + } + + return top || left; } private showContextMenu(container: HTMLElement): void { diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index f208965845..2fc17f4354 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -32,6 +32,7 @@ import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { MementoObject } from 'vs/workbench/common/memento'; import { assertIsDefined } from 'vs/base/common/types'; import { IBoundarySashes } from 'vs/base/browser/ui/grid/gridview'; +import { CompositeDragAndDropObserver } from 'vs/workbench/browser/dnd'; interface IEditorPartUIState { serializedGrid: ISerializedGrid; @@ -826,6 +827,20 @@ export class EditorPart extends Part implements IEditorGroupsService, IEditorGro // Drop support this._register(this.createEditorDropTarget(this.container, {})); + // No drop in the editor + const overlay = document.createElement('div'); + addClass(overlay, 'drop-block-overlay'); + parent.appendChild(overlay); + + CompositeDragAndDropObserver.INSTANCE.registerTarget(this.element, { + onDragStart: e => { + toggleClass(overlay, 'visible', true); + }, + onDragEnd: e => { + toggleClass(overlay, 'visible', false); + } + }); + return this.container; } diff --git a/src/vs/workbench/browser/parts/editor/editorQuickAccess.ts b/src/vs/workbench/browser/parts/editor/editorQuickAccess.ts index ec1cf204fe..93855ab14f 100644 --- a/src/vs/workbench/browser/parts/editor/editorQuickAccess.ts +++ b/src/vs/workbench/browser/parts/editor/editorQuickAccess.ts @@ -3,8 +3,9 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import 'vs/css!./media/editorquickaccess'; import { localize } from 'vs/nls'; -import { IQuickPickSeparator, quickPickItemScorerAccessor, IQuickPickItemWithResource, IQuickPick } from 'vs/platform/quickinput/common/quickInput'; +import { IQuickPickSeparator, quickPickItemScorerAccessor, IQuickPickItemWithResource } from 'vs/platform/quickinput/common/quickInput'; import { PickerQuickAccessProvider, IPickerQuickAccessItem, TriggerAction } from 'vs/platform/quickinput/browser/pickerQuickAccess'; import { IEditorGroupsService, GroupsOrder } from 'vs/workbench/services/editor/common/editorGroupsService'; import { EditorsOrder, IEditorIdentifier, toResource, SideBySideEditor } from 'vs/workbench/common/editor'; @@ -25,18 +26,14 @@ export abstract class BaseEditorQuickAccessProvider extends PickerQuickAccessPro @IModelService private readonly modelService: IModelService, @IModeService private readonly modeService: IModeService ) { - super(prefix); - } - - protected configure(picker: IQuickPick): void { - - // Allow to open editors in background without closing picker - picker.canAcceptInBackground = true; + super(prefix, { canAcceptInBackground: true }); } protected getPicks(filter: string): Array { const query = prepareQuery(filter); const scorerCache = Object.create(null); + + // Filtering const filteredEditorEntries = this.doGetEditorPickItems().filter(entry => { if (!query.value) { return true; @@ -102,11 +99,11 @@ export abstract class BaseEditorQuickAccessProvider extends PickerQuickAccessPro description: editor.getDescription(), iconClasses: getIconClasses(this.modelService, this.modeService, resource), italic: !this.editorGroupService.getGroup(groupId)?.isPinned(editor), - buttonsAlwaysVisible: isDirty, buttons: [ { - iconClass: isDirty ? 'codicon-circle-filled' : 'codicon-close', - tooltip: localize('closeEditor', "Close Editor") + iconClass: isDirty ? 'dirty-editor codicon-circle-filled' : 'codicon-close', + tooltip: localize('closeEditor', "Close Editor"), + alwaysVisible: isDirty } ], trigger: async () => { diff --git a/src/vs/workbench/browser/parts/editor/media/editordroptarget.css b/src/vs/workbench/browser/parts/editor/media/editordroptarget.css index 0d0208ffe2..14d8377e35 100644 --- a/src/vs/workbench/browser/parts/editor/media/editordroptarget.css +++ b/src/vs/workbench/browser/parts/editor/media/editordroptarget.css @@ -22,8 +22,9 @@ opacity: 0; /* hidden initially */ transition: opacity 150ms ease-out; + /* color: red; */ } #monaco-workbench-editor-drop-overlay > .editor-group-overlay-indicator.overlay-move-transition { transition: top 70ms ease-out, left 70ms ease-out, width 70ms ease-out, height 70ms ease-out, opacity 150ms ease-out; -} \ No newline at end of file +} diff --git a/src/vs/workbench/browser/parts/editor/media/editorquickaccess.css b/src/vs/workbench/browser/parts/editor/media/editorquickaccess.css new file mode 100644 index 0000000000..f306827d96 --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/media/editorquickaccess.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.quick-input-list .quick-input-list-entry.has-actions:hover .quick-input-list-entry-action-bar .action-label.dirty-editor::before { + content: "\ea76"; /* Close icon flips between black dot and "X" for dirty open editors */ +} diff --git a/src/vs/workbench/browser/parts/panel/media/panelpart.css b/src/vs/workbench/browser/parts/panel/media/panelpart.css index e186ed9aee..5d18ea3a84 100644 --- a/src/vs/workbench/browser/parts/panel/media/panelpart.css +++ b/src/vs/workbench/browser/parts/panel/media/panelpart.css @@ -85,6 +85,40 @@ display: flex; } +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item::before, +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item::after { + content: ''; + width: 2px; + display: block; + background-color: var(--insert-border-color); + opacity: 0; + transition-property: opacity; + transition-duration: 0ms; + transition-delay: 100ms; +} + +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item::before { + margin-left: -11px; + margin-right: 9px; +} + +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item::after { + margin-right: -11px; + margin-left: 9px; +} + +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item.right::before, +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item.left::after, +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item.left::before, +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item.right::after { + transition-delay: 0s; +} + +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item.left::before, +.monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item.right::after { + opacity: 1; +} + .monaco-workbench .part.panel > .composite.title> .panel-switcher-container > .monaco-action-bar .action-item .action-label{ margin-right: 0; } diff --git a/src/vs/workbench/browser/parts/panel/panelPart.ts b/src/vs/workbench/browser/parts/panel/panelPart.ts index 9dad515c24..eea601ec4b 100644 --- a/src/vs/workbench/browser/parts/panel/panelPart.ts +++ b/src/vs/workbench/browser/parts/panel/panelPart.ts @@ -144,9 +144,8 @@ export class PanelPart extends CompositePart implements IPanelService { getDefaultCompositeId: () => this.panelRegistry.getDefaultPanelId(), hidePart: () => this.layoutService.setPanelHidden(true), dndHandler: new CompositeDragAndDrop(this.viewDescriptorService, ViewContainerLocation.Panel, - (id: string, focus?: boolean) => (this.openPanel(id, focus)) as Promise, // {{SQL CARBON EDIT}} strict-null-check - (from: string, to: string) => this.compositeBar.move(from, to), - () => this.getPinnedPanels().map(p => p.id) + (id: string, focus?: boolean) => this.openPanel(id, focus) as Promise, // {{SQL CARBON EDIT}} strict-null-checks + (from: string, to: string, before?: boolean) => this.compositeBar.move(from, to, before) ), compositeSize: 0, overflowActionSize: 44, diff --git a/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts b/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts index cb25aef3df..5c03e9439a 100644 --- a/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts +++ b/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts @@ -23,7 +23,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { Event, Emitter } from 'vs/base/common/event'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { contrastBorder } from 'vs/platform/theme/common/colorRegistry'; -import { SIDE_BAR_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND, SIDE_BAR_BORDER } from 'vs/workbench/common/theme'; +import { SIDE_BAR_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND, SIDE_BAR_BORDER, SIDE_BAR_DRAG_AND_DROP_BACKGROUND } from 'vs/workbench/common/theme'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { EventType, addDisposableListener, trackFocus } from 'vs/base/browser/dom'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; @@ -33,9 +33,7 @@ import { IExtensionService } from 'vs/workbench/services/extensions/common/exten import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { LayoutPriority } from 'vs/base/browser/ui/grid/grid'; import { assertIsDefined } from 'vs/base/common/types'; -import { LocalSelectionTransfer } from 'vs/workbench/browser/dnd'; -import { DraggedViewIdentifier } from 'vs/workbench/browser/parts/views/viewPaneContainer'; -import { DraggedCompositeIdentifier } from 'vs/workbench/browser/parts/compositeBarActions'; +import { LocalSelectionTransfer, DraggedViewIdentifier, DraggedCompositeIdentifier } from 'vs/workbench/browser/dnd'; export class SidebarPart extends CompositePart implements IViewletService { @@ -209,6 +207,7 @@ export class SidebarPart extends CompositePart implements IViewletServi container.style.borderLeftWidth = borderColor && !isPositionLeft ? '1px' : ''; container.style.borderLeftStyle = borderColor && !isPositionLeft ? 'solid' : ''; container.style.borderLeftColor = !isPositionLeft ? borderColor || '' : ''; + container.style.outlineColor = this.getColor(SIDE_BAR_DRAG_AND_DROP_BACKGROUND) ?? ''; } layout(width: number, height: number): void { diff --git a/src/vs/workbench/browser/parts/views/viewPaneContainer.ts b/src/vs/workbench/browser/parts/views/viewPaneContainer.ts index d5d44d12c3..ec56a89b4c 100644 --- a/src/vs/workbench/browser/parts/views/viewPaneContainer.ts +++ b/src/vs/workbench/browser/parts/views/viewPaneContainer.ts @@ -6,9 +6,9 @@ import 'vs/css!./media/paneviewlet'; import * as nls from 'vs/nls'; import { Event, Emitter } from 'vs/base/common/event'; -import { ColorIdentifier } from 'vs/platform/theme/common/colorRegistry'; +import { ColorIdentifier, activeContrastBorder } from 'vs/platform/theme/common/colorRegistry'; import { attachStyler, IColorMapping, attachButtonStyler, attachLinkStyler, attachProgressBarStyler } from 'vs/platform/theme/common/styler'; -import { SIDE_BAR_DRAG_AND_DROP_BACKGROUND, SIDE_BAR_SECTION_HEADER_FOREGROUND, SIDE_BAR_SECTION_HEADER_BACKGROUND, SIDE_BAR_SECTION_HEADER_BORDER, PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; +import { SIDE_BAR_DRAG_AND_DROP_BACKGROUND, SIDE_BAR_SECTION_HEADER_FOREGROUND, SIDE_BAR_SECTION_HEADER_BACKGROUND, SIDE_BAR_SECTION_HEADER_BORDER, PANEL_BACKGROUND, SIDE_BAR_BACKGROUND, EDITOR_DRAG_AND_DROP_BACKGROUND } from 'vs/workbench/common/theme'; import { append, $, trackFocus, toggleClass, EventType, isAncestor, Dimension, addDisposableListener, removeClass, addClass } from 'vs/base/browser/dom'; import { IDisposable, combinedDisposable, dispose, toDisposable, Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { firstIndex } from 'vs/base/common/arrays'; @@ -20,8 +20,8 @@ import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { PaneView, IPaneViewOptions, IPaneOptions, Pane, DefaultPaneDndController } from 'vs/base/browser/ui/splitview/paneview'; +import { IThemeService, Themable } from 'vs/platform/theme/common/themeService'; +import { PaneView, IPaneViewOptions, IPaneOptions, Pane } from 'vs/base/browser/ui/splitview/paneview'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; @@ -42,11 +42,12 @@ import { parseLinkedText } from 'vs/base/common/linkedText'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { Button } from 'vs/base/browser/ui/button/button'; import { Link } from 'vs/platform/opener/browser/link'; -import { LocalSelectionTransfer } from 'vs/workbench/browser/dnd'; +import { CompositeDragAndDropObserver, DragAndDropObserver } from 'vs/workbench/browser/dnd'; import { Orientation } from 'vs/base/browser/ui/sash/sash'; import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; import { CompositeProgressIndicator } from 'vs/workbench/services/progress/browser/progressIndicator'; import { IProgressIndicator } from 'vs/platform/progress/common/progress'; +import { RunOnceScheduler } from 'vs/base/common/async'; export interface IPaneColors extends IColorMapping { dropBackground?: ColorIdentifier; @@ -61,14 +62,6 @@ export interface IViewPaneOptions extends IPaneOptions { titleMenuId?: MenuId; } -export class DraggedViewIdentifier { - constructor(private _viewId: string) { } - - get id(): string { - return this._viewId; - } -} - type WelcomeActionClassification = { viewId: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; uri: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; @@ -508,6 +501,210 @@ interface IViewPaneItem { disposable: IDisposable; } +const enum DropDirection { + UP, + DOWN, + LEFT, + RIGHT +} + +class ViewPaneDropOverlay extends Themable { + + private static readonly OVERLAY_ID = 'monaco-workbench-pane-drop-overlay'; + + private container!: HTMLElement; + private overlay!: HTMLElement; + + private _currentDropOperation: DropDirection | undefined; + + // private currentDropOperation: IDropOperation | undefined; + private _disposed: boolean | undefined; + + private cleanupOverlayScheduler: RunOnceScheduler; + + get currentDropOperation(): DropDirection | undefined { + return this._currentDropOperation; + } + + constructor( + private paneElement: HTMLElement, + private orientation: Orientation, + protected themeService: IThemeService + ) { + super(themeService); + this.cleanupOverlayScheduler = this._register(new RunOnceScheduler(() => this.dispose(), 300)); + + this.create(); + } + + get disposed(): boolean { + return !!this._disposed; + } + + private create(): void { + // Container + this.container = document.createElement('div'); + this.container.id = ViewPaneDropOverlay.OVERLAY_ID; + + // Parent + this.paneElement.appendChild(this.container); + addClass(this.paneElement, 'dragged-over'); + this._register(toDisposable(() => { + this.paneElement.removeChild(this.container); + removeClass(this.paneElement, 'dragged-over'); + })); + + // Overlay + this.overlay = document.createElement('div'); + addClass(this.overlay, 'pane-overlay-indicator'); + this.container.appendChild(this.overlay); + + // Overlay Event Handling + this.registerListeners(); + + // Styles + this.updateStyles(); + } + + protected updateStyles(): void { + + // Overlay drop background + this.overlay.style.backgroundColor = this.getColor(EDITOR_DRAG_AND_DROP_BACKGROUND) || ''; + + // Overlay contrast border (if any) + const activeContrastBorderColor = this.getColor(activeContrastBorder); + this.overlay.style.outlineColor = activeContrastBorderColor || ''; + this.overlay.style.outlineOffset = activeContrastBorderColor ? '-2px' : ''; + this.overlay.style.outlineStyle = activeContrastBorderColor ? 'dashed' : ''; + this.overlay.style.outlineWidth = activeContrastBorderColor ? '2px' : ''; + + this.overlay.style.borderColor = activeContrastBorderColor || ''; + this.overlay.style.borderStyle = 'solid' || ''; + } + + private registerListeners(): void { + this._register(new DragAndDropObserver(this.container, { + onDragEnter: e => undefined, + onDragOver: e => { + + // Position overlay + this.positionOverlay(e.offsetX, e.offsetY); + + // Make sure to stop any running cleanup scheduler to remove the overlay + if (this.cleanupOverlayScheduler.isScheduled()) { + this.cleanupOverlayScheduler.cancel(); + } + }, + + onDragLeave: e => this.dispose(), + onDragEnd: e => this.dispose(), + + onDrop: e => { + // Dispose overlay + this.dispose(); + } + })); + + this._register(addDisposableListener(this.container, EventType.MOUSE_OVER, () => { + // Under some circumstances we have seen reports where the drop overlay is not being + // cleaned up and as such the editor area remains under the overlay so that you cannot + // type into the editor anymore. This seems related to using VMs and DND via host and + // guest OS, though some users also saw it without VMs. + // To protect against this issue we always destroy the overlay as soon as we detect a + // mouse event over it. The delay is used to guarantee we are not interfering with the + // actual DROP event that can also trigger a mouse over event. + if (!this.cleanupOverlayScheduler.isScheduled()) { + this.cleanupOverlayScheduler.schedule(); + } + })); + } + + private positionOverlay(mousePosX: number, mousePosY: number): void { + const paneWidth = this.paneElement.clientWidth; + const paneHeight = this.paneElement.clientHeight; + + const splitWidthThreshold = paneWidth / 2; + const splitHeightThreshold = paneHeight / 2; + + let dropDirection: DropDirection | undefined; + + if (this.orientation === Orientation.VERTICAL) { + if (mousePosY < splitHeightThreshold) { + dropDirection = DropDirection.UP; + } else if (mousePosY >= splitHeightThreshold) { + dropDirection = DropDirection.DOWN; + } + } else { + if (mousePosX < splitWidthThreshold) { + dropDirection = DropDirection.LEFT; + } else if (mousePosX >= splitWidthThreshold) { + dropDirection = DropDirection.RIGHT; + } + } + + // Draw overlay based on split direction + switch (dropDirection) { + case DropDirection.UP: + this.doPositionOverlay({ top: '0', left: '0', width: '100%', height: '50%' }); + break; + case DropDirection.DOWN: + this.doPositionOverlay({ bottom: '0', left: '0', width: '100%', height: '50%' }); + break; + case DropDirection.LEFT: + this.doPositionOverlay({ top: '0', left: '0', width: '50%', height: '100%' }); + break; + case DropDirection.RIGHT: + this.doPositionOverlay({ top: '0', right: '0', width: '50%', height: '100%' }); + break; + default: + this.doPositionOverlay({ top: '0', left: '0', width: '100%', height: '100%' }); + } + + this.doUpdateOverlayBorder(dropDirection); + + // Make sure the overlay is visible now + this.overlay.style.opacity = '1'; + + // Enable transition after a timeout to prevent initial animation + setTimeout(() => addClass(this.overlay, 'overlay-move-transition'), 0); + + // Remember as current split direction + this._currentDropOperation = dropDirection; + } + + private doUpdateOverlayBorder(direction: DropDirection | undefined): void { + this.overlay.style.borderTopWidth = direction === DropDirection.UP ? '2px' : '0px'; + this.overlay.style.borderLeftWidth = direction === DropDirection.LEFT ? '2px' : '0px'; + this.overlay.style.borderBottomWidth = direction === DropDirection.DOWN ? '2px' : '0px'; + this.overlay.style.borderRightWidth = direction === DropDirection.RIGHT ? '2px' : '0px'; + } + + private doPositionOverlay(options: { top?: string, bottom?: string, left?: string, right?: string, width: string, height: string }): void { + + // Container + this.container.style.height = '100%'; + + // Overlay + this.overlay.style.top = options.top || ''; + this.overlay.style.left = options.left || ''; + this.overlay.style.bottom = options.bottom || ''; + this.overlay.style.right = options.right || ''; + this.overlay.style.width = options.width; + this.overlay.style.height = options.height; + } + + + contains(element: HTMLElement): boolean { + return element === this.container || element === this.overlay; + } + + dispose(): void { + super.dispose(); + + this._disposed = true; + } +} + export class ViewPaneContainer extends Component implements IViewPaneContainer { readonly viewContainer: ViewContainer; @@ -515,8 +712,6 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { private paneItems: IViewPaneItem[] = []; private paneview?: PaneView; - private static viewTransfer = LocalSelectionTransfer.getInstance(); - private visible: boolean = false; private areExtensionsReady: boolean = false; @@ -583,10 +778,6 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { throw new Error('Could not find container'); } - // Use default pane dnd controller if not specified - if (!this.options.dnd) { - this.options.dnd = new DefaultPaneDndController(); - } this.viewContainer = container; this.visibleViewsStorageId = `${id}.numberOfVisibleViews`; @@ -949,19 +1140,104 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { this.paneItems.splice(index, 0, paneItem); assertIsDefined(this.paneview).addPane(pane, size, index); - this._register(addDisposableListener(pane.draggableElement, EventType.DRAG_START, (e: DragEvent) => { - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - } + let overlay: ViewPaneDropOverlay | undefined; - // Register as dragged to local transfer - ViewPaneContainer.viewTransfer.setData([new DraggedViewIdentifier(pane.id)], DraggedViewIdentifier.prototype); - })); + this._register(CompositeDragAndDropObserver.INSTANCE.registerDraggable(pane.draggableElement, 'view', pane.id, {})); + this._register(CompositeDragAndDropObserver.INSTANCE.registerTarget(pane.dropTargetElement, { + onDragEnter: (e) => { + if (!overlay) { + const dropData = e.dragAndDropData.getData(); + if (dropData.type === 'view' && dropData.id !== pane.id) { - this._register(addDisposableListener(pane.draggableElement, EventType.DRAG_END, (e: DragEvent) => { - if (ViewPaneContainer.viewTransfer.hasData(DraggedViewIdentifier.prototype)) { - ViewPaneContainer.viewTransfer.clearData(DraggedViewIdentifier.prototype); + const oldViewContainer = this.viewDescriptorService.getViewContainer(dropData.id); + const viewDescriptor = this.viewDescriptorService.getViewDescriptor(dropData.id); + + if (oldViewContainer !== this.viewContainer && (!viewDescriptor || !viewDescriptor.canMoveView)) { + return; + } + + overlay = new ViewPaneDropOverlay(pane.dropTargetElement, this.options.orientation ?? Orientation.VERTICAL, this.themeService); + } + + if (dropData.type === 'composite' && dropData.id !== this.viewContainer.id) { + const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + + const container = viewContainerRegistry.get(dropData.id)!; + const viewsToMove = this.viewDescriptorService.getViewDescriptors(container).allViewDescriptors; + + if (viewsToMove.length === 1 && viewsToMove[0].canMoveView) { + overlay = new ViewPaneDropOverlay(pane.dropTargetElement, this.options.orientation ?? Orientation.VERTICAL, this.themeService); + } + } + + } + }, + onDragLeave: (e) => { + overlay?.dispose(); + overlay = undefined; + }, + onDrop: (e) => { + if (overlay) { + const dropData = e.dragAndDropData.getData(); + + if (dropData.type === 'composite' && dropData.id !== this.viewContainer.id) { + const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + + const container = viewContainerRegistry.get(dropData.id)!; + const viewsToMove = this.viewDescriptorService.getViewDescriptors(container).allViewDescriptors; + + if (viewsToMove.length === 1 && viewsToMove[0].canMoveView) { + dropData.type = 'view'; + dropData.id = viewsToMove[0].id; + } + } + + if (dropData.type === 'view') { + + const oldViewContainer = this.viewDescriptorService.getViewContainer(dropData.id); + const viewDescriptor = this.viewDescriptorService.getViewDescriptor(dropData.id); + if (oldViewContainer !== this.viewContainer && viewDescriptor && viewDescriptor.canMoveView) { + this.viewDescriptorService.moveViewsToContainer([viewDescriptor], this.viewContainer); + } + + if (overlay.currentDropOperation === DropDirection.DOWN || + overlay.currentDropOperation === DropDirection.RIGHT) { + + const fromIndex = this.panes.findIndex(p => p.id === dropData.id); + let toIndex = this.panes.findIndex(p => p.id === pane.id); + + if (fromIndex >= 0 && toIndex >= 0) { + if (fromIndex > toIndex) { + toIndex++; + } + + if (toIndex < this.panes.length && toIndex !== fromIndex) { + this.movePane(this.panes[fromIndex], this.panes[toIndex]); + } + } + } + + if (overlay.currentDropOperation === DropDirection.UP || + overlay.currentDropOperation === DropDirection.LEFT) { + const fromIndex = this.panes.findIndex(p => p.id === dropData.id); + let toIndex = this.panes.findIndex(p => p.id === pane.id); + + if (fromIndex >= 0 && toIndex >= 0) { + if (fromIndex < toIndex) { + toIndex--; + } + + if (toIndex >= 0 && toIndex !== fromIndex) { + this.movePane(this.panes[fromIndex], this.panes[toIndex]); + } + } + } + } + } + + overlay?.dispose(); + overlay = undefined; } })); } diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 1c28991ac5..68c7132f48 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -130,7 +130,7 @@ class BrowserMain extends Disposable { } private restoreBaseTheme(): void { - addClass(this.domElement, window.localStorage.getItem('vscode.baseTheme') || getThemeTypeSelector(DARK)); + addClass(this.domElement, window.localStorage.getItem('vscode.baseTheme') || getThemeTypeSelector(LIGHT) /* Fallback to a light theme by default on web */); } private saveBaseTheme(): void { diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindReplaceWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindReplaceWidget.css new file mode 100644 index 0000000000..057d97dfb5 --- /dev/null +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindReplaceWidget.css @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .simple-fr-find-part-wrapper { + overflow: hidden; + z-index: 10; + position: absolute; + top: -45px; + right: 18px; + width: 318px; + max-width: calc(100% - 28px - 28px - 8px); + pointer-events: none; + transition: top 200ms linear; + visibility: hidden; +} + +.monaco-workbench .simple-fr-find-part { + /* visibility: hidden; Use visibility to maintain flex layout while hidden otherwise interferes with transition */ + z-index: 10; + position: relative; + top: 0px; + display: flex; + padding: 4px; + align-items: center; + pointer-events: all; + margin: 0 0 0 17px; +} + +.monaco-workbench .simple-fr-replace-part { + /* visibility: hidden; Use visibility to maintain flex layout while hidden otherwise interferes with transition */ + z-index: 10; + position: relative; + top: 0px; + display: flex; + padding: 4px; + align-items: center; + pointer-events: all; + margin: 0 0 0 17px; +} + +.monaco-workbench .simple-fr-find-part-wrapper .find-replace-progress { + width: 100%; + height: 2px; + position: absolute; +} + +.monaco-workbench .simple-fr-find-part-wrapper .find-replace-progress .monaco-progress-container { + height: 2px; + top: 0px !important; + z-index: 100 !important; +} + +.monaco-workbench .simple-fr-find-part-wrapper .find-replace-progress .monaco-progress-container .progress-bit { + height: 2px; +} + +.monaco-workbench .simple-fr-find-part-wrapper .monaco-findInput { + width: 224px; +} + +.monaco-workbench .simple-fr-find-part-wrapper .button { + width: 20px; + height: 20px; + flex: initial; + margin-left: 3px; + background-position: 50%; + background-repeat: no-repeat; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.monaco-workbench .simple-fr-find-part-wrapper.visible .simple-fr-find-part { + visibility: visible; +} + +.monaco-workbench .simple-fr-find-part-wrapper .toggle { + position: absolute; + top: 0; + width: 18px; + height: 100%; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + margin-left: 0px; + pointer-events: all; +} + +.monaco-workbench .simple-fr-find-part-wrapper.visible { + visibility: visible; +} + +.monaco-workbench .simple-fr-find-part-wrapper.visible-transition { + top: 0; +} + +.monaco-workbench .simple-fr-find-part .monaco-findInput { + flex: 1; +} + +.monaco-workbench .simple-fr-find-part .button { + min-width: 20px; + width: 20px; + height: 20px; + display: flex; + flex: initial; + margin-left: 3px; + background-position: center center; + background-repeat: no-repeat; + cursor: pointer; +} + +.monaco-workbench .simple-fr-find-part .button.disabled { + opacity: 0.3; + cursor: default; +} diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindReplaceWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindReplaceWidget.ts new file mode 100644 index 0000000000..a31ecd7e50 --- /dev/null +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindReplaceWidget.ts @@ -0,0 +1,427 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./simpleFindReplaceWidget'; +import * as nls from 'vs/nls'; +import * as dom from 'vs/base/browser/dom'; +import { FindInput, IFindInputStyles } from 'vs/base/browser/ui/findinput/findInput'; +import { Widget } from 'vs/base/browser/ui/widget'; +import { Delayer } from 'vs/base/common/async'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { FindReplaceState, FindReplaceStateChangedEvent } from 'vs/editor/contrib/find/findState'; +import { IMessage as InputBoxMessage } from 'vs/base/browser/ui/inputbox/inputBox'; +import { SimpleButton } from 'vs/editor/contrib/find/findWidget'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { editorWidgetBackground, inputActiveOptionBorder, inputActiveOptionBackground, inputBackground, inputBorder, inputForeground, inputValidationErrorBackground, inputValidationErrorBorder, inputValidationErrorForeground, inputValidationInfoBackground, inputValidationInfoBorder, inputValidationInfoForeground, inputValidationWarningBackground, inputValidationWarningBorder, inputValidationWarningForeground, widgetShadow, editorWidgetForeground } from 'vs/platform/theme/common/colorRegistry'; +import { IColorTheme, registerThemingParticipant, IThemeService } from 'vs/platform/theme/common/themeService'; +import { ContextScopedFindInput, ContextScopedReplaceInput } from 'vs/platform/browser/contextScopedHistoryWidget'; +import { ReplaceInput, IReplaceInputStyles } from 'vs/base/browser/ui/findinput/replaceInput'; +import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; +import { attachProgressBarStyler } from 'vs/platform/theme/common/styler'; + +const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); +const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); +const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous match"); +const NLS_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next match"); +const NLS_CLOSE_BTN_LABEL = nls.localize('label.closeButton', "Close"); +const NLS_TOGGLE_REPLACE_MODE_BTN_LABEL = nls.localize('label.toggleReplaceButton', "Toggle Replace mode"); +const NLS_REPLACE_INPUT_LABEL = nls.localize('label.replace', "Replace"); +const NLS_REPLACE_INPUT_PLACEHOLDER = nls.localize('placeholder.replace', "Replace"); +const NLS_REPLACE_BTN_LABEL = nls.localize('label.replaceButton', "Replace"); +const NLS_REPLACE_ALL_BTN_LABEL = nls.localize('label.replaceAllButton', "Replace All"); + +export abstract class SimpleFindReplaceWidget extends Widget { + protected readonly _findInput: FindInput; + private readonly _domNode: HTMLElement; + private readonly _innerFindDomNode: HTMLElement; + private readonly _focusTracker: dom.IFocusTracker; + private readonly _findInputFocusTracker: dom.IFocusTracker; + private readonly _updateHistoryDelayer: Delayer; + private readonly prevBtn: SimpleButton; + private readonly nextBtn: SimpleButton; + + private readonly _replaceInput!: ReplaceInput; + private readonly _innerReplaceDomNode!: HTMLElement; + private _toggleReplaceBtn!: SimpleButton; + private readonly _replaceInputFocusTracker!: dom.IFocusTracker; + private _replaceBtn!: SimpleButton; + private _replaceAllBtn!: SimpleButton; + + + private _isVisible: boolean = false; + private _isReplaceVisible: boolean = false; + private foundMatch: boolean = false; + + protected _progressBar!: ProgressBar; + + + constructor( + @IContextViewService private readonly _contextViewService: IContextViewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService private readonly _themeService: IThemeService, + private readonly _state: FindReplaceState = new FindReplaceState(), + showOptionButtons?: boolean + ) { + super(); + + this._domNode = document.createElement('div'); + this._domNode.classList.add('simple-fr-find-part-wrapper'); + this._register(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e))); + + let progressContainer = dom.$('.find-replace-progress'); + this._progressBar = new ProgressBar(progressContainer); + this._register(attachProgressBarStyler(this._progressBar, this._themeService)); + this._domNode.appendChild(progressContainer); + + // Toggle replace button + this._toggleReplaceBtn = this._register(new SimpleButton({ + label: NLS_TOGGLE_REPLACE_MODE_BTN_LABEL, + className: 'codicon toggle left', + onTrigger: () => { + this._isReplaceVisible = !this._isReplaceVisible; + this._state.change({ isReplaceRevealed: this._isReplaceVisible }, false); + if (this._isReplaceVisible) { + this._innerReplaceDomNode.style.display = 'flex'; + } else { + this._innerReplaceDomNode.style.display = 'none'; + } + } + })); + this._toggleReplaceBtn.toggleClass('codicon-chevron-down', this._isReplaceVisible); + this._toggleReplaceBtn.toggleClass('codicon-chevron-right', !this._isReplaceVisible); + this._toggleReplaceBtn.setExpanded(this._isReplaceVisible); + this._domNode.appendChild(this._toggleReplaceBtn.domNode); + + + this._innerFindDomNode = document.createElement('div'); + this._innerFindDomNode.classList.add('simple-fr-find-part'); + + this._findInput = this._register(new ContextScopedFindInput(null, this._contextViewService, { + label: NLS_FIND_INPUT_LABEL, + placeholder: NLS_FIND_INPUT_PLACEHOLDER, + validation: (value: string): InputBoxMessage | null => { + if (value.length === 0 || !this._findInput.getRegex()) { + return null; + } + try { + new RegExp(value); + return null; + } catch (e) { + this.foundMatch = false; + this.updateButtons(this.foundMatch); + return { content: e.message }; + } + } + }, contextKeyService, showOptionButtons)); + + // Find History with update delayer + this._updateHistoryDelayer = new Delayer(500); + + this.oninput(this._findInput.domNode, (e) => { + this.foundMatch = this.onInputChanged(); + this.updateButtons(this.foundMatch); + this._delayedUpdateHistory(); + }); + + this._findInput.setRegex(!!this._state.isRegex); + this._findInput.setCaseSensitive(!!this._state.matchCase); + this._findInput.setWholeWords(!!this._state.wholeWord); + + this._register(this._findInput.onDidOptionChange(() => { + this._state.change({ + isRegex: this._findInput.getRegex(), + wholeWord: this._findInput.getWholeWords(), + matchCase: this._findInput.getCaseSensitive() + }, true); + })); + + this._register(this._state.onFindReplaceStateChange(() => { + this._findInput.setRegex(this._state.isRegex); + this._findInput.setWholeWords(this._state.wholeWord); + this._findInput.setCaseSensitive(this._state.matchCase); + this.findFirst(); + })); + + this.prevBtn = this._register(new SimpleButton({ + label: NLS_PREVIOUS_MATCH_BTN_LABEL, + className: 'codicon codicon-arrow-up', + onTrigger: () => { + this.find(true); + } + })); + + this.nextBtn = this._register(new SimpleButton({ + label: NLS_NEXT_MATCH_BTN_LABEL, + className: 'codicon codicon-arrow-down', + onTrigger: () => { + this.find(false); + } + })); + + const closeBtn = this._register(new SimpleButton({ + label: NLS_CLOSE_BTN_LABEL, + className: 'codicon codicon-close', + onTrigger: () => { + this.hide(); + } + })); + + this._innerFindDomNode.appendChild(this._findInput.domNode); + this._innerFindDomNode.appendChild(this.prevBtn.domNode); + this._innerFindDomNode.appendChild(this.nextBtn.domNode); + this._innerFindDomNode.appendChild(closeBtn.domNode); + + // _domNode wraps _innerDomNode, ensuring that + this._domNode.appendChild(this._innerFindDomNode); + + this.onkeyup(this._innerFindDomNode, e => { + if (e.equals(KeyCode.Escape)) { + this.hide(); + e.preventDefault(); + return; + } + }); + + this._focusTracker = this._register(dom.trackFocus(this._innerFindDomNode)); + this._register(this._focusTracker.onDidFocus(this.onFocusTrackerFocus.bind(this))); + this._register(this._focusTracker.onDidBlur(this.onFocusTrackerBlur.bind(this))); + + this._findInputFocusTracker = this._register(dom.trackFocus(this._findInput.domNode)); + this._register(this._findInputFocusTracker.onDidFocus(this.onFindInputFocusTrackerFocus.bind(this))); + this._register(this._findInputFocusTracker.onDidBlur(this.onFindInputFocusTrackerBlur.bind(this))); + + this._register(dom.addDisposableListener(this._innerFindDomNode, 'click', (event) => { + event.stopPropagation(); + })); + + // Replace + this._innerReplaceDomNode = document.createElement('div'); + this._innerReplaceDomNode.classList.add('simple-fr-replace-part'); + + this._replaceInput = this._register(new ContextScopedReplaceInput(null, undefined, { + label: NLS_REPLACE_INPUT_LABEL, + placeholder: NLS_REPLACE_INPUT_PLACEHOLDER, + history: [] + }, contextKeyService, false)); + this._innerReplaceDomNode.appendChild(this._replaceInput.domNode); + this._replaceInputFocusTracker = this._register(dom.trackFocus(this._replaceInput.domNode)); + this._register(this._replaceInputFocusTracker.onDidFocus(this.onReplaceInputFocusTrackerFocus.bind(this))); + this._register(this._replaceInputFocusTracker.onDidBlur(this.onReplaceInputFocusTrackerBlur.bind(this))); + + this._domNode.appendChild(this._innerReplaceDomNode); + + if (this._isReplaceVisible) { + this._innerReplaceDomNode.style.display = 'flex'; + } else { + this._innerReplaceDomNode.style.display = 'none'; + } + + this._replaceBtn = this._register(new SimpleButton({ + label: NLS_REPLACE_BTN_LABEL, + className: 'codicon codicon-replace', + onTrigger: () => { + this.replaceOne(); + } + })); + + // Replace all button + this._replaceAllBtn = this._register(new SimpleButton({ + label: NLS_REPLACE_ALL_BTN_LABEL, + className: 'codicon codicon-replace-all', + onTrigger: () => { + this.replaceAll(); + } + })); + + this._innerReplaceDomNode.appendChild(this._replaceBtn.domNode); + this._innerReplaceDomNode.appendChild(this._replaceAllBtn.domNode); + + + } + + protected abstract onInputChanged(): boolean; + protected abstract find(previous: boolean): void; + protected abstract findFirst(): void; + protected abstract replaceOne(): void; + protected abstract replaceAll(): void; + protected abstract onFocusTrackerFocus(): void; + protected abstract onFocusTrackerBlur(): void; + protected abstract onFindInputFocusTrackerFocus(): void; + protected abstract onFindInputFocusTrackerBlur(): void; + protected abstract onReplaceInputFocusTrackerFocus(): void; + protected abstract onReplaceInputFocusTrackerBlur(): void; + + protected get inputValue() { + return this._findInput.getValue(); + } + + protected get replaceValue() { + return this._replaceInput.getValue(); + } + + public get focusTracker(): dom.IFocusTracker { + return this._focusTracker; + } + + public updateTheme(theme: IColorTheme): void { + const inputStyles: IFindInputStyles = { + inputActiveOptionBorder: theme.getColor(inputActiveOptionBorder), + inputActiveOptionBackground: theme.getColor(inputActiveOptionBackground), + inputBackground: theme.getColor(inputBackground), + inputForeground: theme.getColor(inputForeground), + inputBorder: theme.getColor(inputBorder), + inputValidationInfoBackground: theme.getColor(inputValidationInfoBackground), + inputValidationInfoForeground: theme.getColor(inputValidationInfoForeground), + inputValidationInfoBorder: theme.getColor(inputValidationInfoBorder), + inputValidationWarningBackground: theme.getColor(inputValidationWarningBackground), + inputValidationWarningForeground: theme.getColor(inputValidationWarningForeground), + inputValidationWarningBorder: theme.getColor(inputValidationWarningBorder), + inputValidationErrorBackground: theme.getColor(inputValidationErrorBackground), + inputValidationErrorForeground: theme.getColor(inputValidationErrorForeground), + inputValidationErrorBorder: theme.getColor(inputValidationErrorBorder) + }; + this._findInput.style(inputStyles); + const replaceStyles: IReplaceInputStyles = { + inputActiveOptionBorder: theme.getColor(inputActiveOptionBorder), + inputActiveOptionBackground: theme.getColor(inputActiveOptionBackground), + inputBackground: theme.getColor(inputBackground), + inputForeground: theme.getColor(inputForeground), + inputBorder: theme.getColor(inputBorder), + inputValidationInfoBackground: theme.getColor(inputValidationInfoBackground), + inputValidationInfoForeground: theme.getColor(inputValidationInfoForeground), + inputValidationInfoBorder: theme.getColor(inputValidationInfoBorder), + inputValidationWarningBackground: theme.getColor(inputValidationWarningBackground), + inputValidationWarningForeground: theme.getColor(inputValidationWarningForeground), + inputValidationWarningBorder: theme.getColor(inputValidationWarningBorder), + inputValidationErrorBackground: theme.getColor(inputValidationErrorBackground), + inputValidationErrorForeground: theme.getColor(inputValidationErrorForeground), + inputValidationErrorBorder: theme.getColor(inputValidationErrorBorder) + }; + this._replaceInput.style(replaceStyles); + } + + private _onStateChanged(e: FindReplaceStateChangedEvent): void { + this._updateButtons(); + } + + private _updateButtons(): void { + this._findInput.setEnabled(this._isVisible); + this._replaceInput.setEnabled(this._isVisible && this._isReplaceVisible); + let findInputIsNonEmpty = (this._state.searchString.length > 0); + this._replaceBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty); + this._replaceAllBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty); + + dom.toggleClass(this._domNode, 'replaceToggled', this._isReplaceVisible); + this._toggleReplaceBtn.toggleClass('codicon-chevron-right', !this._isReplaceVisible); + this._toggleReplaceBtn.toggleClass('codicon-chevron-down', this._isReplaceVisible); + this._toggleReplaceBtn.setExpanded(this._isReplaceVisible); + } + + + dispose() { + super.dispose(); + + if (this._domNode && this._domNode.parentElement) { + this._domNode.parentElement.removeChild(this._domNode); + } + } + + public getDomNode() { + return this._domNode; + } + + public reveal(initialInput?: string): void { + if (initialInput) { + this._findInput.setValue(initialInput); + } + + if (this._isVisible) { + this._findInput.select(); + return; + } + + this._isVisible = true; + this.updateButtons(this.foundMatch); + + setTimeout(() => { + dom.addClass(this._domNode, 'visible'); + dom.addClass(this._domNode, 'visible-transition'); + this._domNode.setAttribute('aria-hidden', 'false'); + this._findInput.select(); + }, 0); + } + + public show(initialInput?: string): void { + if (initialInput && !this._isVisible) { + this._findInput.setValue(initialInput); + } + + this._isVisible = true; + + setTimeout(() => { + dom.addClass(this._domNode, 'visible'); + dom.addClass(this._domNode, 'visible-transition'); + this._domNode.setAttribute('aria-hidden', 'false'); + }, 0); + } + + public hide(): void { + if (this._isVisible) { + dom.removeClass(this._domNode, 'visible-transition'); + this._domNode.setAttribute('aria-hidden', 'true'); + // Need to delay toggling visibility until after Transition, then visibility hidden - removes from tabIndex list + setTimeout(() => { + this._isVisible = false; + this.updateButtons(this.foundMatch); + dom.removeClass(this._domNode, 'visible'); + }, 200); + } + } + + protected _delayedUpdateHistory() { + this._updateHistoryDelayer.trigger(this._updateHistory.bind(this)); + } + + protected _updateHistory() { + this._findInput.inputBox.addToHistory(); + } + + protected _getRegexValue(): boolean { + return this._findInput.getRegex(); + } + + protected _getWholeWordValue(): boolean { + return this._findInput.getWholeWords(); + } + + protected _getCaseSensitiveValue(): boolean { + return this._findInput.getCaseSensitive(); + } + + protected updateButtons(foundMatch: boolean) { + const hasInput = this.inputValue.length > 0; + this.prevBtn.setEnabled(this._isVisible && hasInput && foundMatch); + this.nextBtn.setEnabled(this._isVisible && hasInput && foundMatch); + } +} + +// theming +registerThemingParticipant((theme, collector) => { + const findWidgetBGColor = theme.getColor(editorWidgetBackground); + if (findWidgetBGColor) { + collector.addRule(`.monaco-workbench .simple-fr-find-part-wrapper { background-color: ${findWidgetBGColor} !important; }`); + } + + const widgetForeground = theme.getColor(editorWidgetForeground); + if (widgetForeground) { + collector.addRule(`.monaco-workbench .simple-fr-find-part-wrapper { color: ${widgetForeground}; }`); + } + + const widgetShadowColor = theme.getColor(widgetShadow); + if (widgetShadowColor) { + collector.addRule(`.monaco-workbench .simple-fr-find-part-wrapper { box-shadow: 0 2px 8px ${widgetShadowColor}; }`); + } +}); diff --git a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts index 66e71a3d19..bf43984986 100644 --- a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts @@ -54,6 +54,7 @@ import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { IQuickAccessRegistry, Extensions as QuickAccessExtensions } from 'vs/platform/quickinput/common/quickAccess'; import { StartDebugQuickAccessProvider } from 'vs/workbench/contrib/debug/browser/debugQuickAccess'; +import { DebugProgressContribution } from 'vs/workbench/contrib/debug/browser/debugProgress'; class OpenDebugViewletAction extends ShowViewletAction { public static readonly ID = VIEWLET_ID; @@ -298,6 +299,7 @@ configurationRegistry.registerConfiguration({ // Register Debug Status Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(DebugStatusContribution, LifecyclePhase.Eventually); +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(DebugProgressContribution, LifecyclePhase.Eventually); // Debug toolbar diff --git a/src/vs/workbench/contrib/debug/browser/debugProgress.ts b/src/vs/workbench/contrib/debug/browser/debugProgress.ts new file mode 100644 index 0000000000..6e398c1b8a --- /dev/null +++ b/src/vs/workbench/contrib/debug/browser/debugProgress.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { IDebugService, VIEWLET_ID, IDebugSession } from 'vs/workbench/contrib/debug/common/debug'; +import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress'; +import { dispose, IDisposable } from 'vs/base/common/lifecycle'; + +export class DebugProgressContribution implements IWorkbenchContribution { + + private toDispose: IDisposable[] = []; + + constructor( + @IDebugService private readonly debugService: IDebugService, + @IProgressService private readonly progressService: IProgressService + ) { + let progressListener: IDisposable; + const onFocusSession = (session: IDebugSession | undefined) => { + if (progressListener) { + progressListener.dispose(); + } + if (session) { + progressListener = session.onDidProgressStart(async progressStartEvent => { + const promise = new Promise(r => { + // Show progress until a progress end event comes or the session ends + const listener = Event.any(Event.filter(session.onDidProgressEnd, e => e.body.progressId === progressStartEvent.body.progressId), + session.onDidEndAdapter)(() => { + listener.dispose(); + r(); + }); + }); + + this.progressService.withProgress({ location: VIEWLET_ID }, () => promise); + this.progressService.withProgress({ + location: ProgressLocation.Notification, + title: progressStartEvent.body.title, + cancellable: progressStartEvent.body.cancellable, + silent: true + }, () => promise, () => session.cancel(progressStartEvent.body.progressId)); + }); + } + }; + this.toDispose.push(this.debugService.getViewModel().onDidFocusSession(onFocusSession)); + onFocusSession(this.debugService.getViewModel().focusedSession); + } + + dispose(): void { + dispose(this.toDispose); + } +} diff --git a/src/vs/workbench/contrib/debug/browser/debugSession.ts b/src/vs/workbench/contrib/debug/browser/debugSession.ts index ed8e4ae0d0..2a36496dee 100644 --- a/src/vs/workbench/contrib/debug/browser/debugSession.ts +++ b/src/vs/workbench/contrib/debug/browser/debugSession.ts @@ -603,6 +603,14 @@ export class DebugSession implements IDebugSession { }, token); } + async cancel(progressId: string): Promise { + if (!this.raw) { + return Promise.reject(new Error(localize('noDebugAdapter', "No debug adapter, can not send '{0}'", 'cancel'))); + } + + return this.raw.cancel({ progressId }); + } + //---- threads getThread(threadId: number): Thread | undefined { diff --git a/src/vs/workbench/contrib/debug/browser/debugViewlet.ts b/src/vs/workbench/contrib/debug/browser/debugViewlet.ts index 5b1042d3dc..afa0e78a87 100644 --- a/src/vs/workbench/contrib/debug/browser/debugViewlet.ts +++ b/src/vs/workbench/contrib/debug/browser/debugViewlet.ts @@ -120,6 +120,7 @@ export class DebugViewPaneContainer extends ViewPaneContainer { if (CONTEXT_DEBUG_UX.getValue(this.contextKeyService) === 'simple') { return []; } + if (!this.showInitialDebugActions) { if (!this.debugToolBarMenu) { @@ -185,7 +186,7 @@ export class DebugViewPaneContainer extends ViewPaneContainer { } if (state === State.Initializing) { - this.progressService.withProgress({ location: VIEWLET_ID }, _progress => { + this.progressService.withProgress({ location: VIEWLET_ID, }, _progress => { return new Promise(resolve => this.progressResolve = resolve); }); } diff --git a/src/vs/workbench/contrib/debug/common/debug.ts b/src/vs/workbench/contrib/debug/common/debug.ts index d21a9dd235..011b2f683f 100644 --- a/src/vs/workbench/contrib/debug/common/debug.ts +++ b/src/vs/workbench/contrib/debug/common/debug.ts @@ -223,6 +223,7 @@ export interface IDebugSession extends ITreeElement { variables(variablesReference: number, threadId: number | undefined, filter: 'indexed' | 'named' | undefined, start: number | undefined, count: number | undefined): Promise; evaluate(expression: string, frameId?: number, context?: string): Promise; customRequest(request: string, args: any): Promise; + cancel(progressId: string): Promise; restartFrame(frameId: number, threadId: number): Promise; next(threadId: number): Promise; diff --git a/src/vs/workbench/contrib/debug/common/debugProtocol.d.ts b/src/vs/workbench/contrib/debug/common/debugProtocol.d.ts index 68bc287080..e01ea9ae01 100644 --- a/src/vs/workbench/contrib/debug/common/debugProtocol.d.ts +++ b/src/vs/workbench/contrib/debug/common/debugProtocol.d.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - + /** Declaration module describing the VS Code debug protocol. Auto-generated from json schema. Do not edit manually. */ @@ -72,13 +72,12 @@ declare module DebugProtocol { /** Cancel request; value of command field is 'cancel'. The 'cancel' request is used by the frontend in two situations: - to indicate that it is no longer interested in the result produced by a specific request issued earlier - - to cancel a progress indicator. + - to cancel a progress sequence. This request has a hint characteristic: a debug adapter can only be expected to make a 'best effort' in honouring this request but there are no guarantees. The 'cancel' request may return an error if it could not cancel an operation but a frontend should refrain from presenting this error to end users. A frontend client should only call this request if the capability 'supportsCancelRequest' is true. - The request that got canceled still needs to send a response back. - This can either be a normal result ('success' attribute true) or an error response ('success' attribute false and the 'message' set to 'cancelled'). - Returning partial results from a cancelled request is possible but please note that a frontend client has no generic way for detecting that a response is partial or not. + The request that got canceled still needs to send a response back. This can either be a normal result ('success' attribute true) or an error response ('success' attribute false and the 'message' set to 'cancelled'). Returning partial results from a cancelled request is possible but please note that a frontend client has no generic way for detecting that a response is partial or not. + The progress that got cancelled still needs to send a 'progressEnd' event back. A client should not assume that progress just got cancelled after sending the 'cancel' request. */ export interface CancelRequest extends Request { // command: 'cancel'; diff --git a/src/vs/workbench/contrib/debug/test/common/mockDebug.ts b/src/vs/workbench/contrib/debug/test/common/mockDebug.ts index b11431d5fd..57fb9caa11 100644 --- a/src/vs/workbench/contrib/debug/test/common/mockDebug.ts +++ b/src/vs/workbench/contrib/debug/test/common/mockDebug.ts @@ -134,6 +134,10 @@ export class MockDebugService implements IDebugService { export class MockSession implements IDebugSession { + cancel(_progressId: string): Promise { + throw new Error('Method not implemented.'); + } + breakpointsLocations(uri: uri, lineNumber: number): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index b42b6306e0..c215e2e00a 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -3,7 +3,6 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import 'vs/css!./media/fileactions'; import * as nls from 'vs/nls'; import { isWindows, isWeb } from 'vs/base/common/platform'; import * as extpath from 'vs/base/common/extpath'; diff --git a/src/vs/workbench/contrib/files/browser/views/media/openeditors.css b/src/vs/workbench/contrib/files/browser/views/media/openeditors.css index 63cffe631e..fd2783dc0b 100644 --- a/src/vs/workbench/contrib/files/browser/views/media/openeditors.css +++ b/src/vs/workbench/contrib/files/browser/views/media/openeditors.css @@ -25,6 +25,10 @@ justify-content: center; } +.open-editors .monaco-list .monaco-list-row.dirty:not(:hover) > .monaco-action-bar .codicon-close::before { + content: "\ea71"; /* Close icon flips between black dot and "X" for dirty open editors */ +} + .open-editors .monaco-list .monaco-list-row > .monaco-action-bar .action-close-all-files, .open-editors .monaco-list .monaco-list-row > .monaco-action-bar .save-all { width: 23px; diff --git a/src/vs/workbench/contrib/notebook/browser/constants.ts b/src/vs/workbench/contrib/notebook/browser/constants.ts new file mode 100644 index 0000000000..9196f850da --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/constants.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const INSERT_CODE_CELL_ABOVE_COMMAND_ID = 'workbench.notebook.code.insertCellAbove'; +export const INSERT_CODE_CELL_BELOW_COMMAND_ID = 'workbench.notebook.code.insertCellBelow'; +export const INSERT_MARKDOWN_CELL_ABOVE_COMMAND_ID = 'workbench.notebook.markdown.insertCellAbove'; +export const INSERT_MARKDOWN_CELL_BELOW_COMMAND_ID = 'workbench.notebook.markdown.insertCellAbove'; + +export const EDIT_CELL_COMMAND_ID = 'workbench.notebook.cell.edit'; +export const SAVE_CELL_COMMAND_ID = 'workbench.notebook.cell.save'; +export const DELETE_CELL_COMMAND_ID = 'workbench.notebook.cell.delete'; + +export const MOVE_CELL_UP_COMMAND_ID = 'workbench.notebook.cell.moveUp'; +export const MOVE_CELL_DOWN_COMMAND_ID = 'workbench.notebook.cell.moveDown'; +export const COPY_CELL_UP_COMMAND_ID = 'workbench.notebook.cell.copyUp'; +export const COPY_CELL_DOWN_COMMAND_ID = 'workbench.notebook.cell.copyDown'; + +export const EXECUTE_CELL_COMMAND_ID = 'workbench.notebook.cell.execute'; + +// Cell sizing related +export const CELL_MARGIN = 32; +export const EDITOR_TOP_PADDING = 8; +export const EDITOR_BOTTOM_PADDING = 8; +export const EDITOR_TOOLBAR_HEIGHT = 22; diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookActions.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookActions.ts new file mode 100644 index 0000000000..f43ef1a7b3 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookActions.ts @@ -0,0 +1,956 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { localize } from 'vs/nls'; +import { Action2, IAction2Options, MenuId, MenuItemAction, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { InputFocusedContext, InputFocusedContextKey, IsDevelopmentContext } from 'vs/platform/contextkey/common/contextkeys'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { DELETE_CELL_COMMAND_ID, EDIT_CELL_COMMAND_ID, INSERT_CODE_CELL_ABOVE_COMMAND_ID, INSERT_CODE_CELL_BELOW_COMMAND_ID, INSERT_MARKDOWN_CELL_ABOVE_COMMAND_ID, INSERT_MARKDOWN_CELL_BELOW_COMMAND_ID, MOVE_CELL_DOWN_COMMAND_ID, MOVE_CELL_UP_COMMAND_ID, SAVE_CELL_COMMAND_ID, COPY_CELL_UP_COMMAND_ID, COPY_CELL_DOWN_COMMAND_ID } from 'vs/workbench/contrib/notebook/browser/constants'; +import { INotebookEditor, KEYBINDING_CONTEXT_NOTEBOOK_FIND_WIDGET_FOCUSED, NOTEBOOK_EDITOR_FOCUSED, ICellViewModel, CellState } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { INotebookService } from 'vs/workbench/contrib/notebook/browser/notebookService'; +import { CellKind, NOTEBOOK_EDITOR_CURSOR_BOUNDARY } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.executeNotebookCell', + title: localize('notebookActions.execute', "Execute Notebook Cell"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext), + primary: KeyMod.WinCtrl | KeyCode.Enter, + win: { + primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.Enter + }, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + runActiveCell(accessor); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.executeNotebookCellSelectBelow', + title: localize('notebookActions.executeAndSelectBelow', "Execute Notebook Cell and Select Below"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext), + primary: KeyMod.Shift | KeyCode.Enter, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const activeCell = runActiveCell(accessor); + if (!activeCell) { + return; + } + + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return; + } + + const idx = editor.viewModel?.getViewCellIndex(activeCell); + if (typeof idx !== 'number') { + return; + } + + // Try to select below, fall back on inserting + const nextCell = editor.viewModel?.viewCells[idx + 1]; + if (nextCell) { + editor.focusNotebookCell(nextCell, false); + } else { + await editor.insertNotebookCell(activeCell, CellKind.Code, 'below'); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.executeNotebookCellInsertBelow', + title: localize('notebookActions.executeAndInsertBelow', "Execute Notebook Cell and Insert Below"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext), + primary: KeyMod.Alt | KeyCode.Enter, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const activeCell = runActiveCell(accessor); + if (!activeCell) { + return; + } + + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return; + } + + await editor.insertNotebookCell(activeCell, CellKind.Code, 'below'); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.executeNotebook', + title: localize('notebookActions.executeNotebook', "Execute Notebook") + }); + } + + async run(accessor: ServicesAccessor): Promise { + let editorService = accessor.get(IEditorService); + let notebookService = accessor.get(INotebookService); + + let resource = editorService.activeEditor?.resource; + + if (!resource) { + return; + } + + let notebookProviders = notebookService.getContributedNotebookProviders(resource!); + + if (notebookProviders.length > 0) { + let viewType = notebookProviders[0].id; + notebookService.executeNotebook(viewType, resource); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.quitNotebookEdit', + title: localize('notebookActions.quitEditing', "Quit Notebook Cell Editing"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext), + primary: KeyCode.Escape, + weight: KeybindingWeight.EditorContrib - 5 + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + let editorService = accessor.get(IEditorService); + let editor = getActiveNotebookEditor(editorService); + + if (!editor) { + return; + } + + let activeCell = editor.getActiveCell(); + if (activeCell) { + if (activeCell.cellKind === CellKind.Markdown) { + activeCell.state = CellState.Preview; + } + + editor.focusNotebookCell(activeCell, false); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.notebook.hideFind', + title: localize('notebookActions.hideFind', "Hide Find in Notebook"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, KEYBINDING_CONTEXT_NOTEBOOK_FIND_WIDGET_FOCUSED), + primary: KeyCode.Escape, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + let editorService = accessor.get(IEditorService); + let editor = getActiveNotebookEditor(editorService); + + editor?.hideFind(); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.notebook.find', + title: localize('notebookActions.findInNotebook', "Find in Notebook"), + keybinding: { + when: NOTEBOOK_EDITOR_FOCUSED, + primary: KeyCode.KEY_F | KeyMod.CtrlCmd, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + let editorService = accessor.get(IEditorService); + let editor = getActiveNotebookEditor(editorService); + + editor?.showFind(); + } +}); + +MenuRegistry.appendMenuItem(MenuId.EditorTitle, { + command: { + id: 'workbench.action.executeNotebook', + title: localize('notebookActions.menu.executeNotebook', "Execute Notebook (Run all cells)"), + icon: { id: 'codicon/debug-start' } + }, + order: -1, + group: 'navigation', + when: NOTEBOOK_EDITOR_FOCUSED +}); + + +MenuRegistry.appendMenuItem(MenuId.EditorTitle, { + command: { + id: 'workbench.action.executeNotebookCell', + title: localize('notebookActions.menu.execute', "Execute Notebook Cell"), + icon: { id: 'codicon/debug-continue' } + }, + order: -1, + group: 'navigation', + when: NOTEBOOK_EDITOR_FOCUSED +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.changeCellToCode', + title: localize('notebookActions.changeCellToCode', "Change Cell to Code"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), + primary: KeyCode.KEY_Y, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + return changeActiveCellToKind(CellKind.Code, accessor); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.changeCellToMarkdown', + title: localize('notebookActions.changeCellToMarkdown', "Change Cell to Markdown"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), + primary: KeyCode.KEY_M, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + return changeActiveCellToKind(CellKind.Markdown, accessor); + } +}); + +function getActiveNotebookEditor(editorService: IEditorService): INotebookEditor | undefined { + // TODO can `isNotebookEditor` be on INotebookEditor to avoid a circular dependency? + const activeEditorPane = editorService.activeEditorPane as any | undefined; + return activeEditorPane?.isNotebookEditor ? activeEditorPane : undefined; +} + +function runActiveCell(accessor: ServicesAccessor): ICellViewModel | undefined { + const editorService = accessor.get(IEditorService); + const notebookService = accessor.get(INotebookService); + + const resource = editorService.activeEditor?.resource; + if (!resource) { + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + const notebookProviders = notebookService.getContributedNotebookProviders(resource); + if (!notebookProviders.length) { + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + const activeCell = editor.getActiveCell(); + if (!activeCell) { + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + const idx = editor.viewModel?.getViewCellIndex(activeCell); + if (typeof idx !== 'number') { + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + const viewType = notebookProviders[0].id; + notebookService.executeNotebookActiveCell(viewType, resource); + + return activeCell; +} + +async function changeActiveCellToKind(kind: CellKind, accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return; + } + + const activeCell = editor.getActiveCell(); + if (!activeCell) { + return; + } + + if (activeCell.cellKind === kind) { + return; + } + + const text = activeCell.getText(); + await editor.insertNotebookCell(activeCell, kind, 'below', text); + const idx = editor.viewModel?.getViewCellIndex(activeCell); + if (typeof idx !== 'number') { + return; + } + + const newCell = editor.viewModel?.viewCells[idx + 1]; + if (!newCell) { + return; + } + + editor.focusNotebookCell(newCell, false); + editor.deleteNotebookCell(activeCell); +} + +export interface INotebookCellActionContext { + cell: ICellViewModel; + notebookEditor: INotebookEditor; +} + +function getActiveCellContext(accessor: ServicesAccessor): INotebookCellActionContext | undefined { + const editorService = accessor.get(IEditorService); + + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + const activeCell = editor.getActiveCell(); + if (!activeCell) { + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + return { + cell: activeCell, + notebookEditor: editor + }; +} + +abstract class InsertCellCommand extends Action2 { + constructor( + desc: Readonly, + private kind: CellKind, + private direction: 'above' | 'below' + ) { + super(desc); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext): Promise { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + await context.notebookEditor.insertNotebookCell(context.cell, this.kind, this.direction); + } +} + +registerAction2(class extends InsertCellCommand { + constructor() { + super( + { + id: INSERT_CODE_CELL_ABOVE_COMMAND_ID, + title: localize('notebookActions.insertCodeCellAbove', "Insert Code Cell Above") + }, + CellKind.Code, + 'above'); + } +}); + +registerAction2(class extends InsertCellCommand { + constructor() { + super( + { + id: INSERT_CODE_CELL_BELOW_COMMAND_ID, + title: localize('notebookActions.insertCodeCellBelow', "Insert Code Cell Below") + }, + CellKind.Code, + 'below'); + } +}); + +registerAction2(class extends InsertCellCommand { + constructor() { + super( + { + id: INSERT_MARKDOWN_CELL_BELOW_COMMAND_ID, + title: localize('notebookActions.insertMarkdownCellAbove', "Insert Markdown Cell Above"), + }, + CellKind.Markdown, + 'above'); + } +}); + +registerAction2(class extends InsertCellCommand { + constructor() { + super( + { + id: INSERT_MARKDOWN_CELL_BELOW_COMMAND_ID, + title: localize('notebookActions.insertMarkdownCellBelow', "Insert Markdown Cell Below"), + }, + CellKind.Code, + 'below'); + } +}); + +export class InsertCodeCellAboveAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: INSERT_CODE_CELL_ABOVE_COMMAND_ID, + title: localize('notebookActions.insertCodeCellAbove', "Insert Code Cell Above"), + icon: { id: 'codicon/add' } + }, + undefined, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + } +} + +export class InsertCodeCellBelowAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: INSERT_CODE_CELL_BELOW_COMMAND_ID, + title: localize('notebookActions.insertCodeCellBelow', "Insert Code Cell Below"), + icon: { id: 'codicon/add' } + }, + { + id: INSERT_MARKDOWN_CELL_BELOW_COMMAND_ID, + title: localize('notebookActions.insertMarkdownCellBelow', "Insert Markdown Cell Below"), + icon: { id: 'codicon/add' } + }, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + } +} + +export class InsertMarkdownCellAboveAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: INSERT_MARKDOWN_CELL_ABOVE_COMMAND_ID, + title: localize('notebookActions.insertMarkdownCellAbove', "Insert Markdown Cell Above"), + icon: { id: 'codicon/add' } + }, + undefined, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + } +} + +export class InsertMarkdownCellBelowAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: INSERT_MARKDOWN_CELL_BELOW_COMMAND_ID, + title: localize('notebookActions.insertMarkdownCellBelow', "Insert Markdown Cell Below"), + icon: { id: 'codicon/add' } + }, + undefined, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + } +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: EDIT_CELL_COMMAND_ID, + title: localize('notebookActions.editCell', "Edit Cell"), + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), + primary: KeyCode.Enter, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return context.notebookEditor.editNotebookCell(context.cell); + } +}); + +export class EditCellAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: EDIT_CELL_COMMAND_ID, + title: localize('notebookActions.editCell', "Edit Cell"), + icon: { id: 'codicon/pencil' } + }, + undefined, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + } +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: SAVE_CELL_COMMAND_ID, + title: localize('notebookActions.saveCell', "Save Cell") + }); + } + + run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return context.notebookEditor.saveNotebookCell(context.cell); + } +}); + +export class SaveCellAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: SAVE_CELL_COMMAND_ID, + title: localize('notebookActions.saveCell', "Save Cell"), + icon: { id: 'codicon/save' } + }, + undefined, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + } +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: DELETE_CELL_COMMAND_ID, + title: localize('notebookActions.deleteCell', "Delete Cell") + }); + } + + run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return context.notebookEditor.deleteNotebookCell(context.cell); + } +}); + +export class DeleteCellAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: DELETE_CELL_COMMAND_ID, + title: localize('notebookActions.deleteCell', "Delete Cell"), + icon: { id: 'codicon/x' } + }, + undefined, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + + this.class = 'codicon-x'; + } +} + +async function moveCell(context: INotebookCellActionContext, direction: 'up' | 'down'): Promise { + direction === 'up' ? + context.notebookEditor.moveCellUp(context.cell) : + context.notebookEditor.moveCellDown(context.cell); +} + +async function copyCell(context: INotebookCellActionContext, direction: 'up' | 'down'): Promise { + const text = context.cell.getText(); + const newCellDirection = direction === 'up' ? 'above' : 'below'; + await context.notebookEditor.insertNotebookCell(context.cell, context.cell.cellKind, newCellDirection, text); +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: MOVE_CELL_UP_COMMAND_ID, + title: localize('notebookActions.moveCellUp', "Move Cell Up") + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return moveCell(context, 'up'); + } +}); + +export class MoveCellUpAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: MOVE_CELL_UP_COMMAND_ID, + title: localize('notebookActions.moveCellUp', "Move Cell Up"), + icon: { id: 'codicon/arrow-up' } + }, + { + id: COPY_CELL_UP_COMMAND_ID, + title: localize('notebookActions.copyCellUp', "Copy Cell Up"), + icon: { id: 'codicon/arrow-up' } + }, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + } +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: MOVE_CELL_DOWN_COMMAND_ID, + title: localize('notebookActions.moveCellDown', "Move Cell Down") + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return moveCell(context, 'down'); + } +}); + +export class MoveCellDownAction extends MenuItemAction { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService + ) { + super( + { + id: MOVE_CELL_DOWN_COMMAND_ID, + title: localize('notebookActions.moveCellDown', "Move Cell Down"), + icon: { id: 'codicon/arrow-down' } + }, + { + id: COPY_CELL_DOWN_COMMAND_ID, + title: localize('notebookActions.copyCellDown', "Copy Cell Down"), + icon: { id: 'codicon/arrow-down' } + }, + { shouldForwardArgs: true }, + contextKeyService, + commandService); + + this.class = 'codicon-arrow-down'; + } +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: COPY_CELL_UP_COMMAND_ID, + title: localize('notebookActions.copyCellUp', "Copy Cell Up") + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return copyCell(context, 'up'); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: COPY_CELL_DOWN_COMMAND_ID, + title: localize('notebookActions.copyCellDown', "Copy Cell Down") + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return copyCell(context, 'down'); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.notebook.cursorDown', + title: 'Notebook Cursor Move Down', + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.has(InputFocusedContextKey), NOTEBOOK_EDITOR_CURSOR_BOUNDARY.notEqualsTo('top'), NOTEBOOK_EDITOR_CURSOR_BOUNDARY.notEqualsTo('none')), + primary: KeyCode.DownArrow, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext): Promise { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + const editor = context.notebookEditor; + const activeCell = context.cell; + + const idx = editor.viewModel?.getViewCellIndex(activeCell); + if (typeof idx !== 'number') { + return; + } + + const newCell = editor.viewModel?.viewCells[idx + 1]; + + if (!newCell) { + return; + } + + editor.focusNotebookCell(newCell, true); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.notebook.cursorUp', + title: 'Notebook Cursor Move Up', + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.has(InputFocusedContextKey), NOTEBOOK_EDITOR_CURSOR_BOUNDARY.notEqualsTo('bottom'), NOTEBOOK_EDITOR_CURSOR_BOUNDARY.notEqualsTo('none')), + primary: KeyCode.UpArrow, + weight: KeybindingWeight.WorkbenchContrib + }, + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext): Promise { + if (!context) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + const editor = context.notebookEditor; + const activeCell = context.cell; + + const idx = editor.viewModel?.getViewCellIndex(activeCell); + if (typeof idx !== 'number') { + return; + } + + if (idx < 1) { + // we don't do loop + return; + } + + const newCell = editor.viewModel?.viewCells[idx - 1]; + + if (!newCell) { + return; + } + + editor.focusNotebookCell(newCell, true); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.notebook.undo', + title: 'Notebook Undo', + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), + primary: KeyMod.CtrlCmd | KeyCode.KEY_Z, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return; + } + + const viewModel = editor.viewModel; + + if (!viewModel) { + return; + } + + viewModel.undo(); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.notebook.redo', + title: 'Notebook Redo', + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KEY_Z, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return; + } + + const viewModel = editor.viewModel; + + if (!viewModel) { + return; + } + + viewModel.redo(); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.notebook.testResize', + title: 'Notebook Test Cell Resize', + keybinding: { + when: IsDevelopmentContext, + primary: undefined, + weight: KeybindingWeight.WorkbenchContrib + }, + f1: true + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const resource = editorService.activeEditor?.resource; + if (!resource) { + return; + } + + const editor = getActiveNotebookEditor(editorService); + if (!editor) { + return; + } + + const cells = editor.viewModel?.viewCells; + + if (cells && cells.length) { + const firstCell = cells[0]; + editor.layoutNotebookCell(firstCell, 400); + } + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookFindWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookFindWidget.ts new file mode 100644 index 0000000000..8c893e9a8d --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookFindWidget.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { KEYBINDING_CONTEXT_NOTEBOOK_FIND_WIDGET_FOCUSED, INotebookEditor, CellFindMatch, CellState } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { FindDecorations } from 'vs/editor/contrib/find/findDecorations'; +import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; +import { IModelDeltaDecoration } from 'vs/editor/common/model'; +import { ICellModelDeltaDecorations, ICellModelDecorations } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; +import { PrefixSumComputer } from 'vs/editor/common/viewModel/prefixSumComputer'; +import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { SimpleFindReplaceWidget } from 'vs/workbench/contrib/codeEditor/browser/find/simpleFindReplaceWidget'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; + +export class NotebookFindWidget extends SimpleFindReplaceWidget { + protected _findWidgetFocused: IContextKey; + private _findMatches: CellFindMatch[] = []; + protected _findMatchesStarts: PrefixSumComputer | null = null; + private _currentMatch: number = -1; + private _allMatchesDecorations: ICellModelDecorations[] = []; + private _currentMatchDecorations: ICellModelDecorations[] = []; + + constructor( + private readonly _notebookEditor: INotebookEditor, + @IContextViewService contextViewService: IContextViewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + + ) { + super(contextViewService, contextKeyService, themeService); + this._findWidgetFocused = KEYBINDING_CONTEXT_NOTEBOOK_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); + this._register(this._findInput.onKeyDown((e) => this._onFindInputKeyDown(e))); + } + + private _onFindInputKeyDown(e: IKeyboardEvent): void { + if (e.equals(KeyCode.Enter)) { + if (this._findMatches.length) { + this.find(false); + } else { + this.set(null); + } + e.preventDefault(); + return; + } else if (e.equals(KeyMod.Shift | KeyCode.Enter)) { + if (this._findMatches.length) { + this.find(true); + } else { + this.set(null); + } + e.preventDefault(); + return; + } + } + + protected onInputChanged(): boolean { + const val = this.inputValue; + if (val) { + this._findMatches = this._notebookEditor.viewModel!.find(val).filter(match => match.matches.length > 0); + if (this._findMatches.length) { + return true; + } else { + return false; + } + } + + return false; + } + + protected find(previous: boolean): void { + if (!this._findMatches.length) { + return; + } + + if (!this._findMatchesStarts) { + this.set(this._findMatches); + } else { + const totalVal = this._findMatchesStarts!.getTotalValue(); + const nextVal = (this._currentMatch + (previous ? -1 : 1) + totalVal) % totalVal; + this._currentMatch = nextVal; + } + + + const nextIndex = this._findMatchesStarts!.getIndexOf(this._currentMatch); + this.setCurrentFindMatchDecoration(nextIndex.index, nextIndex.remainder); + this.revealCellRange(nextIndex.index, nextIndex.remainder); + } + + protected replaceOne() { + if (!this._findMatches.length) { + return; + } + + if (!this._findMatchesStarts) { + this.set(this._findMatches); + } + + const nextIndex = this._findMatchesStarts!.getIndexOf(this._currentMatch); + const cell = this._findMatches[nextIndex.index].cell; + const match = this._findMatches[nextIndex.index].matches[nextIndex.remainder]; + + this._progressBar.infinite().show(); + + this._notebookEditor.viewModel!.replaceOne(cell, match.range, this.replaceValue).then(() => { + this._progressBar.stop(); + }); + } + + protected replaceAll() { + this._progressBar.infinite().show(); + + this._notebookEditor.viewModel!.replaceAll(this._findMatches, this.replaceValue).then(() => { + this._progressBar.stop(); + }); + } + + private revealCellRange(cellIndex: number, matchIndex: number) { + this._findMatches[cellIndex].cell.state = CellState.Editing; + this._notebookEditor.selectElement(this._findMatches[cellIndex].cell); + this._notebookEditor.setCellSelection(this._findMatches[cellIndex].cell, this._findMatches[cellIndex].matches[matchIndex].range); + this._notebookEditor.revealRangeInCenterIfOutsideViewport(this._findMatches[cellIndex].cell, this._findMatches[cellIndex].matches[matchIndex].range); + } + + hide() { + super.hide(); + this.set([]); + } + + protected findFirst(): void { } + + protected onFocusTrackerFocus() { + this._findWidgetFocused.set(true); + } + + protected onFocusTrackerBlur() { + this._findWidgetFocused.reset(); + } + + protected onReplaceInputFocusTrackerFocus(): void { + // throw new Error('Method not implemented.'); + } + protected onReplaceInputFocusTrackerBlur(): void { + // throw new Error('Method not implemented.'); + } + + protected onFindInputFocusTrackerFocus(): void { } + protected onFindInputFocusTrackerBlur(): void { } + + private constructFindMatchesStarts() { + if (this._findMatches && this._findMatches.length) { + const values = new Uint32Array(this._findMatches.length); + for (let i = 0; i < this._findMatches.length; i++) { + values[i] = this._findMatches[i].matches.length; + } + + this._findMatchesStarts = new PrefixSumComputer(values); + } else { + this._findMatchesStarts = null; + } + } + + private set(cellFindMatches: CellFindMatch[] | null): void { + if (!cellFindMatches || !cellFindMatches.length) { + this._findMatches = []; + this.setAllFindMatchesDecorations([]); + + this.constructFindMatchesStarts(); + this._currentMatch = -1; + this.clearCurrentFindMatchDecoration(); + return; + } + + // all matches + this._findMatches = cellFindMatches; + this.setAllFindMatchesDecorations(cellFindMatches || []); + + // current match + this.constructFindMatchesStarts(); + this._currentMatch = 0; + this.setCurrentFindMatchDecoration(0, 0); + } + + private setCurrentFindMatchDecoration(cellIndex: number, matchIndex: number) { + this._notebookEditor.changeDecorations(accessor => { + const findMatchesOptions: ModelDecorationOptions = FindDecorations._CURRENT_FIND_MATCH_DECORATION; + + const cell = this._findMatches[cellIndex].cell; + const match = this._findMatches[cellIndex].matches[matchIndex]; + const decorations: IModelDeltaDecoration[] = [ + { range: match.range, options: findMatchesOptions } + ]; + const deltaDecoration: ICellModelDeltaDecorations = { + ownerId: cell.handle, + decorations: decorations + }; + + this._currentMatchDecorations = accessor.deltaDecorations(this._currentMatchDecorations, [deltaDecoration]); + }); + } + + private clearCurrentFindMatchDecoration() { + this._notebookEditor.changeDecorations(accessor => { + this._currentMatchDecorations = accessor.deltaDecorations(this._currentMatchDecorations, []); + }); + } + + private setAllFindMatchesDecorations(cellFindMatches: CellFindMatch[]) { + this._notebookEditor.changeDecorations((accessor) => { + + let findMatchesOptions: ModelDecorationOptions = FindDecorations._FIND_MATCH_DECORATION; + + let deltaDecorations: ICellModelDeltaDecorations[] = cellFindMatches.map(cellFindMatch => { + const findMatches = cellFindMatch.matches; + + // Find matches + let newFindMatchesDecorations: IModelDeltaDecoration[] = new Array(findMatches.length); + for (let i = 0, len = findMatches.length; i < len; i++) { + newFindMatchesDecorations[i] = { + range: findMatches[i].range, + options: findMatchesOptions + }; + } + + return { ownerId: cellFindMatch.cell.handle, decorations: newFindMatchesDecorations }; + }); + + this._allMatchesDecorations = accessor.deltaDecorations(this._allMatchesDecorations, deltaDecorations); + }); + } + + clear() { + this._currentMatch = -1; + this._findMatches = []; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts b/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts new file mode 100644 index 0000000000..db91028db5 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from 'vs/base/common/jsonSchema'; +import * as nls from 'vs/nls'; +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; +import { NotebookSelector } from 'vs/workbench/contrib/notebook/common/notebookProvider'; + +namespace NotebookEditorContribution { + export const viewType = 'viewType'; + export const displayName = 'displayName'; + export const selector = 'selector'; +} + +interface INotebookEditorContribution { + readonly [NotebookEditorContribution.viewType]: string; + readonly [NotebookEditorContribution.displayName]: string; + readonly [NotebookEditorContribution.selector]?: readonly NotebookSelector[]; +} + +namespace NotebookRendererContribution { + export const viewType = 'viewType'; + export const displayName = 'displayName'; + export const mimeTypes = 'mimeTypes'; +} + +interface INotebookRendererContribution { + readonly [NotebookRendererContribution.viewType]: string; + readonly [NotebookRendererContribution.displayName]: string; + readonly [NotebookRendererContribution.mimeTypes]?: readonly string[]; +} + + + +const notebookProviderContribution: IJSONSchema = { + description: nls.localize('contributes.notebook.provider', 'Contributes notebook document provider.'), + type: 'array', + defaultSnippets: [{ body: [{ viewType: '', displayName: '' }] }], + items: { + type: 'object', + required: [ + NotebookEditorContribution.viewType, + NotebookEditorContribution.displayName, + NotebookEditorContribution.selector, + ], + properties: { + [NotebookEditorContribution.viewType]: { + type: 'string', + description: nls.localize('contributes.notebook.provider.viewType', 'Unique identifier of the notebook.'), + }, + [NotebookEditorContribution.displayName]: { + type: 'string', + description: nls.localize('contributes.notebook.provider.displayName', 'Human readable name of the notebook.'), + }, + [NotebookEditorContribution.selector]: { + type: 'array', + description: nls.localize('contributes.notebook.provider.selector', 'Set of globs that the notebook is for.'), + items: { + type: 'object', + properties: { + filenamePattern: { + type: 'string', + description: nls.localize('contributes.notebook.provider.selector.filenamePattern', 'Glob that the notebook is enabled for.'), + }, + excludeFileNamePattern: { + type: 'string', + description: nls.localize('contributes.notebook.selector.provider.excludeFileNamePattern', 'Glob that the notebook is disabled for.') + } + } + } + } + } + } +}; + +const notebookRendererContribution: IJSONSchema = { + description: nls.localize('contributes.notebook.renderer', 'Contributes notebook output renderer provider.'), + type: 'array', + defaultSnippets: [{ body: [{ viewType: '', displayName: '', mimeTypes: [''] }] }], + items: { + type: 'object', + required: [ + NotebookRendererContribution.viewType, + NotebookRendererContribution.displayName, + NotebookRendererContribution.mimeTypes, + ], + properties: { + [NotebookRendererContribution.viewType]: { + type: 'string', + description: nls.localize('contributes.notebook.renderer.viewType', 'Unique identifier of the notebook output renderer.'), + }, + [NotebookRendererContribution.displayName]: { + type: 'string', + description: nls.localize('contributes.notebook.renderer.displayName', 'Human readable name of the notebook output renderer.'), + }, + [NotebookRendererContribution.mimeTypes]: { + type: 'array', + description: nls.localize('contributes.notebook.selector', 'Set of globs that the notebook is for.'), + items: { + type: 'string' + } + } + } + } +}; + +export const notebookProviderExtensionPoint = ExtensionsRegistry.registerExtensionPoint( + { + extensionPoint: 'notebookProvider', + jsonSchema: notebookProviderContribution + }); + +export const notebookRendererExtensionPoint = ExtensionsRegistry.registerExtensionPoint( + { + extensionPoint: 'notebookOutputRenderer', + jsonSchema: notebookRendererContribution + }); diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts new file mode 100644 index 0000000000..d9c036517b --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import { IConfigurationRegistry, Extensions } from 'vs/platform/configuration/common/configurationRegistry'; +import { IEditorOptions, ITextEditorOptions } from 'vs/platform/editor/common/editor'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { EditorDescriptor, Extensions as EditorExtensions, IEditorRegistry } from 'vs/workbench/browser/editor'; +import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; +import { IEditorInput, IEditorInputFactoryRegistry, Extensions as EditorInputExtensions, IEditorInputFactory, EditorInput } from 'vs/workbench/common/editor'; +import { NotebookEditor, NotebookEditorOptions } from 'vs/workbench/contrib/notebook/browser/notebookEditor'; +import { NotebookEditorInput } from 'vs/workbench/contrib/notebook/browser/notebookEditorInput'; +import { INotebookService, NotebookService } from 'vs/workbench/contrib/notebook/browser/notebookService'; +import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorService, IOpenEditorOverride } from 'vs/workbench/services/editor/common/editorService'; +import { ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService'; +import { ITextModel } from 'vs/editor/common/model'; +import { URI } from 'vs/base/common/uri'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { assertType } from 'vs/base/common/types'; +import { parse } from 'vs/base/common/marshalling'; +import { CellUri } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { ResourceMap } from 'vs/base/common/map'; + +// Output renderers registration + +import 'vs/workbench/contrib/notebook/browser/view/output/transforms/streamTransform'; +import 'vs/workbench/contrib/notebook/browser/view/output/transforms/errorTransform'; +import 'vs/workbench/contrib/notebook/browser/view/output/transforms/richTransform'; + +// Actions +import 'vs/workbench/contrib/notebook/browser/contrib/notebookActions'; +import { basename } from 'vs/base/common/resources'; +import { NotebookProviderInfo } from 'vs/workbench/contrib/notebook/common/notebookProvider'; + +Registry.as(EditorExtensions.Editors).registerEditor( + EditorDescriptor.create( + NotebookEditor, + NotebookEditor.ID, + 'Notebook Editor' + ), + [ + new SyncDescriptor(NotebookEditorInput) + ] +); + +Registry.as(EditorInputExtensions.EditorInputFactories).registerEditorInputFactory( + NotebookEditorInput.ID, + class implements IEditorInputFactory { + canSerialize(): boolean { + return true; + } + serialize(input: EditorInput): string { + assertType(input instanceof NotebookEditorInput); + return JSON.stringify({ + resource: input.resource, + name: input.name, + viewType: input.viewType, + }); + } + deserialize(instantiationService: IInstantiationService, raw: string) { + type Data = { resource: URI, name: string, viewType: string }; + const data = parse(raw); + if (!data) { + return undefined; + } + const { resource, name, viewType } = data; + if (!data || !URI.isUri(resource) || typeof name !== 'string' || typeof viewType !== 'string') { + return undefined; + } + // TODO@joh,peng this is disabled because the note-editor isn't fit for being + // restorted (as it seems) + if ('true') { + return undefined; + } + return instantiationService.createInstance(NotebookEditorInput, resource, name, viewType); + } + } +); + +function getFirstNotebookInfo(notebookService: INotebookService, uri: URI): NotebookProviderInfo | undefined { + return notebookService.getContributedNotebookProviders(uri)[0]; +} + +export class NotebookContribution implements IWorkbenchContribution { + private _resourceMapping = new ResourceMap(); + + constructor( + @IEditorService private readonly editorService: IEditorService, + @INotebookService private readonly notebookService: INotebookService, + @IInstantiationService private readonly instantiationService: IInstantiationService + + ) { + this.editorService.overrideOpenEditor((editor, options, group) => this.onEditorOpening(editor, options, group)); + + this.editorService.onDidActiveEditorChange(() => { + if (this.editorService.activeEditor && this.editorService.activeEditor! instanceof NotebookEditorInput) { + let editorInput = this.editorService.activeEditor! as NotebookEditorInput; + this.notebookService.updateActiveNotebookDocument(editorInput.viewType!, editorInput.resource!); + } + }); + } + + private onEditorOpening(originalInput: IEditorInput, options: IEditorOptions | ITextEditorOptions | undefined, group: IEditorGroup): IOpenEditorOverride | undefined { + let resource = originalInput.resource; + if (!resource) { + return undefined; + } + + let info: NotebookProviderInfo | undefined; + const data = CellUri.parse(resource); + if (data && (info = getFirstNotebookInfo(this.notebookService, data.notebook))) { + // cell-uri -> open (container) notebook + const name = basename(data.notebook); + const input = this.instantiationService.createInstance(NotebookEditorInput, data.notebook, name, info.id); + this._resourceMapping.set(resource, input); + return { override: this.editorService.openEditor(input, new NotebookEditorOptions({ ...options, forceReload: true, cellOptions: { resource, options } }), group) }; + } + + info = getFirstNotebookInfo(this.notebookService, resource); + if (!info) { + return undefined; + } + + if (this._resourceMapping.has(resource)) { + const input = this._resourceMapping.get(resource); + + if (!input!.isDisposed()) { + return { override: this.editorService.openEditor(input!, new NotebookEditorOptions(options || {}).with({ ignoreOverrides: true }), group) }; + } + } + + const input = this.instantiationService.createInstance(NotebookEditorInput, resource, originalInput.getName(), info.id); + this._resourceMapping.set(resource, input); + + return { override: this.editorService.openEditor(input, options, group) }; + } +} + +class CellContentProvider implements ITextModelContentProvider { + + private readonly _registration: IDisposable; + + constructor( + @ITextModelService textModelService: ITextModelService, + @IModelService private readonly _modelService: IModelService, + @IModeService private readonly _modeService: IModeService, + @INotebookService private readonly _notebookService: INotebookService, + ) { + this._registration = textModelService.registerTextModelContentProvider('vscode-notebook', this); + } + + dispose(): void { + this._registration.dispose(); + } + + async provideTextContent(resource: URI): Promise { + const existing = this._modelService.getModel(resource); + if (existing) { + return existing; + } + const data = CellUri.parse(resource); + // const data = parseCellUri(resource); + if (!data) { + return null; + } + const info = getFirstNotebookInfo(this._notebookService, data.notebook); + if (!info) { + return null; + } + const notebook = await this._notebookService.resolveNotebook(info.id, data.notebook); + if (!notebook) { + return null; + } + for (let cell of notebook.cells) { + if (cell.uri.toString() === resource.toString()) { + let bufferFactory = cell.resolveTextBufferFactory(); + return this._modelService.createModel( + bufferFactory, + cell.language ? this._modeService.create(cell.language) : this._modeService.createByFilepathOrFirstLine(resource, cell.source[0]), + resource + ); + } + } + + return null; + } +} + +const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); +workbenchContributionsRegistry.registerWorkbenchContribution(NotebookContribution, LifecyclePhase.Starting); +workbenchContributionsRegistry.registerWorkbenchContribution(CellContentProvider, LifecyclePhase.Starting); + +registerSingleton(INotebookService, NotebookService); + +const configurationRegistry = Registry.as(Extensions.Configuration); +configurationRegistry.registerConfiguration({ + id: 'notebook', + order: 100, + title: nls.localize('notebookConfigurationTitle', "Notebook"), + type: 'object', + properties: { + 'notebook.displayOrder': { + markdownDescription: nls.localize('notebook.displayOrder.description', "Priority list for output mime types"), + type: ['array'], + items: { + type: 'string' + }, + default: [] + } + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.css b/src/vs/workbench/contrib/notebook/browser/notebook.css new file mode 100644 index 0000000000..b6765b347d --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebook.css @@ -0,0 +1,318 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .part.editor > .content .notebook-editor { + box-sizing: border-box; + line-height: 22px; + user-select: initial; + -webkit-user-select: initial; + position: relative; +} + +.cell.markdown { + user-select: text; + -webkit-user-select: text; + white-space: initial; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell-list-container .monaco-scrollable-element { + overflow: visible !important; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell-list-container .monaco-list-rows { + min-height: 100%; + overflow: visible !important; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell-list-container { + position: relative; +} + +.monaco-workbench .part.editor > .content .notebook-editor .notebook-content-widgets { + position: absolute; + top: 0; + left: 0; + width: 100%; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output { + padding-left: 8px; + padding-right: 8px; + user-select: text; + transform: translate3d(0px, 0px, 0px); + cursor: auto; + box-sizing: border-box; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output p { + white-space: initial; + overflow-x: auto; + margin: 0px; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output > div.foreground { + padding: 8px; + box-sizing: border-box; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output .multi-mimetype-output { + position: absolute; + top: 4px; + left: -28px; + width: 16px; + height: 16px; + cursor: pointer; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output .error_message { + color: red; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output pre.traceback { + margin: 8px 0; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output .traceback > span { + display: block; +} + +.monaco-workbench .part.editor > .content .notebook-editor .output .display img { + max-width: 100%; +} + + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row { + overflow: visible !important; +} + + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row:focus-within { + z-index: 10; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row .menu { + position: absolute; + left: 0; + top: 28px; + visibility: hidden; + width: 16px; + margin: auto; + padding-left: 4px; +} + + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row .menu.mouseover, +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row:hover .menu { + visibility: visible; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list .monaco-list-row:hover { + outline: none !important; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list .monaco-list-row.selected, +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list .monaco-list-row.focused { + outline: none !important; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row .menu.mouseover, +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row .menu:hover { + cursor: pointer; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row .monaco-toolbar { + visibility: hidden; + margin-right: 24px; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row.focused .monaco-toolbar, +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row:hover .monaco-toolbar { + visibility: visible; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-tree.focused.no-focused-item:focus:before, +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list:not(.element-focused):focus:before { + outline: none !important; +} + + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list .monaco-list-row .notebook-cell-focus-indicator { + display: block; + content: ' '; + position: absolute; + width: 6px; + border-left-width: 2px; + border-left-style: solid; + left: 28px; + top: 22px; + bottom: 8px; + visibility: hidden; +} + +.monaco-workbench .part.editor > .content .notebook-editor .monaco-list .monaco-list-row.focused .notebook-cell-focus-indicator { + visibility: visible; +} + +.notebook-webview { + position: absolute; + z-index: 1000000; + left: 373px; + top: 0px; +} + +/* markdown */ + + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown img { + max-width: 100%; + max-height: 100%; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown a { + text-decoration: none; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown a:hover { + text-decoration: underline; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown a:focus, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown input:focus, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown select:focus, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown textarea:focus { + outline: 1px solid -webkit-focus-ring-color; + outline-offset: -1px; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown hr { + border: 0; + height: 2px; + border-bottom: 2px solid; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown h1 { + padding-bottom: 0.3em; + line-height: 1.2; + border-bottom-width: 1px; + border-bottom-style: solid; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown h1, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown h2, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown h3 { + font-weight: normal; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table { + border-collapse: collapse; + border-spacing: 0; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table th, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table td { + border: 1px solid ; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > thead > tr > th { + text-align: left; + border-bottom: 1px solid; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > thead > tr > th, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > thead > tr > td, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > tbody > tr > th, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > tbody > tr > td { + padding: 5px 10px; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > tbody > tr + tr > td { + border-top: 1px solid; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown blockquote { + margin: 0 7px 0 5px; + padding: 0 16px 0 10px; + border-left-width: 5px; + border-left-style: solid; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown code { + font-family: Menlo, Monaco, Consolas, "Droid Sans Mono", "Courier New", monospace, "Droid Sans Fallback"; + font-size: 1em; + line-height: 1.357em; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown body.wordWrap pre { + white-space: pre-wrap; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown pre:not(.hljs), +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown pre.hljs code > div { + padding: 16px; + border-radius: 3px; + overflow: auto; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown pre code { + color: var(--vscode-editor-foreground); + tab-size: 4; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown .latex-block { + display: block; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown .latex { + vertical-align: middle; + display: inline-block; +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown .latex img, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown .latex-block img { + filter: brightness(0) invert(0) +} + +.vs-dark .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown .latex img, +.vs-dark .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown .latex-block img { + filter: brightness(0) invert(1) +} + +/** Theming */ + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown pre { + background-color: rgba(220, 220, 220, 0.4); +} + +.vs-dark .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown pre { + background-color: rgba(10, 10, 10, 0.4); +} + +.hc-black .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown pre { + background-color: rgb(0, 0, 0); +} + +.hc-black .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown h1 { + border-color: rgb(0, 0, 0); +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > thead > tr > th { + border-color: rgba(0, 0, 0, 0.18); +} + +.vs-dark .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > thead > tr > th { + border-color: rgba(255, 255, 255, 0.18); +} + +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown h1, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown hr, +.monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > tbody > tr > td { + border-color: rgba(0, 0, 0, 0.18); +} + +.vs-dark .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown h1, +.vs-dark .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown hr, +.vs-dark .monaco-workbench .part.editor > .content .notebook-editor .cell.markdown table > tbody > tr > td { + border-color: rgba(255, 255, 255, 0.18); +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts new file mode 100644 index 0000000000..9cb4243d64 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -0,0 +1,256 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; +import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { OutputRenderer } from 'vs/workbench/contrib/notebook/browser/view/output/outputRenderer'; +import { IOutput, CellKind, IRenderOutput } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { NotebookViewModel, IModelDecorationsChangeAccessor } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; +import { FindMatch } from 'vs/editor/common/model'; +import { Range } from 'vs/editor/common/core/range'; +import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; + +export const KEYBINDING_CONTEXT_NOTEBOOK_FIND_WIDGET_FOCUSED = new RawContextKey('notebookFindWidgetFocused', false); + +export const NOTEBOOK_EDITOR_FOCUSED = new RawContextKey('notebookEditorFocused', false); + +export interface NotebookLayoutInfo { + width: number; + height: number; + fontInfo: BareFontInfo; +} + +export interface ICellViewModel { + readonly id: string; + handle: number; + uri: URI; + cellKind: CellKind; + state: CellState; + focusMode: CellFocusMode; + getText(): string; +} + +export interface INotebookEditor { + + /** + * Notebook view model attached to the current editor + */ + viewModel: NotebookViewModel | undefined; + + /** + * Focus the notebook editor cell list + */ + focus(): void; + + /** + * Select & focus cell + */ + selectElement(cell: ICellViewModel): void; + + /** + * Layout info for the notebook editor + */ + getLayoutInfo(): NotebookLayoutInfo; + /** + * Fetch the output renderers for notebook outputs. + */ + getOutputRenderer(): OutputRenderer; + + /** + * Insert a new cell around `cell` + */ + insertNotebookCell(cell: ICellViewModel, type: CellKind, direction: 'above' | 'below', initialText?: string): Promise; + + /** + * Delete a cell from the notebook + */ + deleteNotebookCell(cell: ICellViewModel): void; + + /** + * Move a cell up one spot + */ + moveCellUp(cell: ICellViewModel): void; + + /** + * Move a cell down one spot + */ + moveCellDown(cell: ICellViewModel): void; + + /** + * Switch the cell into editing mode. + * + * For code cell, the monaco editor will be focused. + * For markdown cell, it will switch from preview mode to editing mode, which focuses the monaco editor. + */ + editNotebookCell(cell: ICellViewModel): void; + + /** + * Quit cell editing mode. + */ + saveNotebookCell(cell: ICellViewModel): void; + + /** + * Focus the container of a cell (the monaco editor inside is not focused). + */ + focusNotebookCell(cell: ICellViewModel, focusEditor: boolean): void; + + /** + * Get current active cell + */ + getActiveCell(): ICellViewModel | undefined; + + /** + * Layout the cell with a new height + */ + layoutNotebookCell(cell: ICellViewModel, height: number): void; + + /** + * Render the output in webview layer + */ + createInset(cell: ICellViewModel, output: IOutput, shadowContent: string, offset: number): void; + + /** + * Remove the output from the webview layer + */ + removeInset(output: IOutput): void; + + /** + * Trigger the editor to scroll from scroll event programmatically + */ + triggerScroll(event: IMouseWheelEvent): void; + + /** + * Reveal cell into viewport. + */ + revealInView(cell: ICellViewModel): void; + + /** + * Reveal cell into viewport center. + */ + revealInCenter(cell: ICellViewModel): void; + + /** + * Reveal cell into viewport center if cell is currently out of the viewport. + */ + revealInCenterIfOutsideViewport(cell: ICellViewModel): void; + + /** + * Reveal a line in notebook cell into viewport with minimal scrolling. + */ + revealLineInView(cell: ICellViewModel, line: number): void; + + /** + * Reveal a line in notebook cell into viewport center. + */ + revealLineInCenter(cell: ICellViewModel, line: number): void; + + /** + * Reveal a line in notebook cell into viewport center. + */ + revealLineInCenterIfOutsideViewport(cell: ICellViewModel, line: number): void; + + /** + * Reveal a range in notebook cell into viewport with minimal scrolling. + */ + revealRangeInView(cell: ICellViewModel, range: Range): void; + + /** + * Reveal a range in notebook cell into viewport center. + */ + revealRangeInCenter(cell: ICellViewModel, range: Range): void; + + /** + * Reveal a range in notebook cell into viewport center. + */ + revealRangeInCenterIfOutsideViewport(cell: ICellViewModel, range: Range): void; + + setCellSelection(cell: ICellViewModel, selection: Range): void; + + /** + * Change the decorations on cells. + * The notebook is virtualized and this method should be called to create/delete editor decorations safely. + */ + changeDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => any): any; + + /** + * Show Find Widget. + * + * Currently Find is still part of the NotebookEditor core + */ + showFind(): void; + + /** + * Hide Find Widget + */ + hideFind(): void; +} + +export interface CellRenderTemplate { + container: HTMLElement; + cellContainer: HTMLElement; + menuContainer?: HTMLElement; + toolbar: ToolBar; + focusIndicator?: HTMLElement; + editingContainer?: HTMLElement; + outputContainer?: HTMLElement; + editor?: CodeEditorWidget; + disposables: DisposableStore; +} + +export interface IOutputTransformContribution { + /** + * Dispose this contribution. + */ + dispose(): void; + + render(output: IOutput, container: HTMLElement, preferredMimeType: string | undefined): IRenderOutput; +} + +export interface CellFindMatch { + cell: CellViewModel; + matches: FindMatch[]; +} + +export enum CellRevealType { + Line, + Range +} + +export enum CellRevealPosition { + Top, + Center +} + +export enum CellState { + /** + * Default state. + * For markdown cell, it's Markdown preview. + * For code cell, the browser focus should be on the container instead of the editor + */ + Preview, + + + /** + * Eding mode. Source for markdown or code is rendered in editors and the state will be persistent. + */ + Editing +} + +export enum CellFocusMode { + Container, + Editor +} + +export enum CursorAtBoundary { + None, + Top, + Bottom, + Both +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts new file mode 100644 index 0000000000..f42c05b9fa --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts @@ -0,0 +1,760 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import { getZoomLevel } from 'vs/base/browser/browser'; +import * as DOM from 'vs/base/browser/dom'; +import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; +import 'vs/css!./notebook'; +import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IStorageService } from 'vs/platform/storage/common/storage'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { contrastBorder, editorBackground, focusBorder, foreground, textBlockQuoteBackground, textBlockQuoteBorder, textLinkActiveForeground, textLinkForeground, textPreformatForeground, registerColor } from 'vs/platform/theme/common/colorRegistry'; +import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; +import { EditorOptions, IEditorMemento, IEditorCloseEvent } from 'vs/workbench/common/editor'; +import { INotebookEditor, NotebookLayoutInfo, CellState, NOTEBOOK_EDITOR_FOCUSED, CellFocusMode, ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { NotebookEditorInput, NotebookEditorModel } from 'vs/workbench/contrib/notebook/browser/notebookEditorInput'; +import { INotebookService } from 'vs/workbench/contrib/notebook/browser/notebookService'; +import { OutputRenderer } from 'vs/workbench/contrib/notebook/browser/view/output/outputRenderer'; +import { BackLayerWebView } from 'vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView'; +import { CodeCellRenderer, MarkdownCellRenderer, NotebookCellListDelegate } from 'vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer'; +import { IOutput, CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IWebviewService } from 'vs/workbench/contrib/webview/browser/webview'; +import { getExtraColor } from 'vs/workbench/contrib/welcome/walkThrough/common/walkThroughUtils'; +import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { IEditor, ICompositeCodeEditor } from 'vs/editor/common/editorCommon'; +import { IResourceEditorInput } from 'vs/platform/editor/common/editor'; +import { Emitter, Event } from 'vs/base/common/event'; +import { NotebookCellList } from 'vs/workbench/contrib/notebook/browser/view/notebookCellList'; +import { NotebookFindWidget } from 'vs/workbench/contrib/notebook/browser/contrib/notebookFindWidget'; +import { NotebookViewModel, INotebookEditorViewState, IModelDecorationsChangeAccessor } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; +import { IEditorGroupView } from 'vs/workbench/browser/parts/editor/editor'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { Range } from 'vs/editor/common/core/range'; +import { CELL_MARGIN } from 'vs/workbench/contrib/notebook/browser/constants'; +import { Color, RGBA } from 'vs/base/common/color'; + +const $ = DOM.$; +const NOTEBOOK_EDITOR_VIEW_STATE_PREFERENCE_KEY = 'NotebookEditorViewState'; + +export class NotebookEditorOptions extends EditorOptions { + + readonly cellOptions?: IResourceEditorInput; + + constructor(options: Partial) { + super(); + this.overwrite(options); + this.cellOptions = options.cellOptions; + } + + with(options: Partial): NotebookEditorOptions { + return new NotebookEditorOptions({ ...this, ...options }); + } +} + +export class NotebookCodeEditors implements ICompositeCodeEditor { + + private readonly _disposables = new DisposableStore(); + private readonly _onDidChangeActiveEditor = new Emitter(); + readonly onDidChangeActiveEditor: Event = this._onDidChangeActiveEditor.event; + + constructor( + private _list: NotebookCellList, + private _renderedEditors: Map + ) { + _list.onDidChangeFocus(_e => this._onDidChangeActiveEditor.fire(this), undefined, this._disposables); + } + + dispose(): void { + this._onDidChangeActiveEditor.dispose(); + this._disposables.dispose(); + } + + get activeCodeEditor(): IEditor | undefined { + const [focused] = this._list.getFocusedElements(); + return focused instanceof CellViewModel + ? this._renderedEditors.get(focused) + : undefined; + } +} + +export class NotebookEditor extends BaseEditor implements INotebookEditor { + static readonly ID: string = 'workbench.editor.notebook'; + private rootElement!: HTMLElement; + private body!: HTMLElement; + private webview: BackLayerWebView | null = null; + private list: NotebookCellList | undefined; + private control: ICompositeCodeEditor | undefined; + private renderedEditors: Map = new Map(); + private notebookViewModel: NotebookViewModel | undefined; + private localStore: DisposableStore = this._register(new DisposableStore()); + private editorMemento: IEditorMemento; + private readonly groupListener = this._register(new MutableDisposable()); + private fontInfo: BareFontInfo | undefined; + private dimension: DOM.Dimension | null = null; + private editorFocus: IContextKey | null = null; + private outputRenderer: OutputRenderer; + private findWidget: NotebookFindWidget; + + constructor( + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService storageService: IStorageService, + @IWebviewService private webviewService: IWebviewService, + @INotebookService private notebookService: INotebookService, + @IEditorGroupsService editorGroupService: IEditorGroupsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEnvironmentService private readonly environmentSerice: IEnvironmentService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(NotebookEditor.ID, telemetryService, themeService, storageService); + + this.editorMemento = this.getEditorMemento(editorGroupService, NOTEBOOK_EDITOR_VIEW_STATE_PREFERENCE_KEY); + this.outputRenderer = new OutputRenderer(this, this.instantiationService); + this.findWidget = this.instantiationService.createInstance(NotebookFindWidget, this); + this.findWidget.updateTheme(this.themeService.getColorTheme()); + } + + get viewModel() { + return this.notebookViewModel; + } + + get minimumWidth(): number { return 375; } + get maximumWidth(): number { return Number.POSITIVE_INFINITY; } + + // these setters need to exist because this extends from BaseEditor + set minimumWidth(value: number) { /*noop*/ } + set maximumWidth(value: number) { /*noop*/ } + + + //#region Editor Core + + + public get isNotebookEditor() { + return true; + } + + protected createEditor(parent: HTMLElement): void { + this.rootElement = DOM.append(parent, $('.notebook-editor')); + this.createBody(this.rootElement); + this.generateFontInfo(); + this.editorFocus = NOTEBOOK_EDITOR_FOCUSED.bindTo(this.contextKeyService); + this._register(this.onDidFocus(() => { + this.editorFocus?.set(true); + })); + + this._register(this.onDidBlur(() => { + this.editorFocus?.set(false); + })); + } + + private generateFontInfo(): void { + const editorOptions = this.configurationService.getValue('editor'); + this.fontInfo = BareFontInfo.createFromRawSettings(editorOptions, getZoomLevel()); + } + + private createBody(parent: HTMLElement): void { + this.body = document.createElement('div'); + DOM.addClass(this.body, 'cell-list-container'); + this.createCellList(); + DOM.append(parent, this.body); + DOM.append(parent, this.findWidget.getDomNode()); + } + + private createCellList(): void { + DOM.addClass(this.body, 'cell-list-container'); + + const renders = [ + this.instantiationService.createInstance(CodeCellRenderer, this, this.renderedEditors), + this.instantiationService.createInstance(MarkdownCellRenderer, this), + ]; + + this.list = this.instantiationService.createInstance( + NotebookCellList, + 'NotebookCellList', + this.body, + this.instantiationService.createInstance(NotebookCellListDelegate), + renders, + this.contextKeyService, + { + setRowLineHeight: false, + setRowHeight: false, + supportDynamicHeights: true, + horizontalScrolling: false, + keyboardSupport: false, + mouseSupport: true, + multipleSelectionSupport: false, + enableKeyboardNavigation: true, + overrideStyles: { + listBackground: editorBackground, + listActiveSelectionBackground: editorBackground, + listActiveSelectionForeground: foreground, + listFocusAndSelectionBackground: editorBackground, + listFocusAndSelectionForeground: foreground, + listFocusBackground: editorBackground, + listFocusForeground: foreground, + listHoverForeground: foreground, + listHoverBackground: editorBackground, + listHoverOutline: focusBorder, + listFocusOutline: focusBorder, + listInactiveSelectionBackground: editorBackground, + listInactiveSelectionForeground: foreground, + listInactiveFocusBackground: editorBackground, + listInactiveFocusOutline: editorBackground, + } + }, + ); + + this.control = new NotebookCodeEditors(this.list, this.renderedEditors); + this.webview = new BackLayerWebView(this.webviewService, this.notebookService, this, this.environmentSerice); + this.list.rowsContainer.appendChild(this.webview.element); + this._register(this.list); + } + + getControl() { + return this.control; + } + + onHide() { + this.editorFocus?.set(false); + if (this.webview) { + this.localStore.clear(); + this.list?.rowsContainer.removeChild(this.webview?.element); + this.webview?.dispose(); + this.webview = null; + } + + this.list?.splice(0, this.list?.length); + + if (this.notebookViewModel && !this.notebookViewModel.isDirty()) { + this.notebookService.destoryNotebookDocument(this.notebookViewModel.viewType!, this.notebookViewModel!.notebookDocument); + this.notebookViewModel.dispose(); + this.notebookViewModel = undefined; + } + + super.onHide(); + } + + setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { + super.setEditorVisible(visible, group); + this.groupListener.value = ((group as IEditorGroupView).onWillCloseEditor(e => this.onWillCloseEditorInGroup(e))); + } + + private onWillCloseEditorInGroup(e: IEditorCloseEvent): void { + const editor = e.editor; + if (!(editor instanceof NotebookEditorInput)) { + return; // only handle files + } + + if (editor === this.input) { + this.saveTextEditorViewState(editor); + } + } + + focus() { + super.focus(); + this.editorFocus?.set(true); + } + + async setInput(input: NotebookEditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { + if (this.input instanceof NotebookEditorInput) { + this.saveTextEditorViewState(this.input); + } + + await super.setInput(input, options, token); + const model = await input.resolve(); + + if (this.notebookViewModel === undefined || !this.notebookViewModel.equal(model) || this.webview === null) { + this.detachModel(); + await this.attachModel(input, model); + } + + // reveal cell if editor options tell to do so + if (options instanceof NotebookEditorOptions && options.cellOptions) { + const cellOptions = options.cellOptions; + const cell = this.notebookViewModel!.viewCells.find(cell => cell.uri.toString() === cellOptions.resource.toString()); + if (cell) { + this.revealInCenterIfOutsideViewport(cell); + const editor = this.renderedEditors.get(cell)!; + if (editor) { + if (cellOptions.options?.selection) { + const { selection } = cellOptions.options; + editor.setSelection({ + ...selection, + endLineNumber: selection.endLineNumber || selection.startLineNumber, + endColumn: selection.endColumn || selection.startColumn + }); + } + if (!cellOptions.options?.preserveFocus) { + editor.focus(); + } + } + } + } + } + + clearInput(): void { + if (this.input && this.input instanceof NotebookEditorInput && !this.input.isDisposed()) { + this.saveTextEditorViewState(this.input); + } + + super.clearInput(); + } + + private detachModel() { + this.localStore.clear(); + this.notebookViewModel?.dispose(); + this.notebookViewModel = undefined; + this.webview?.clearInsets(); + this.webview?.clearPreloadsCache(); + this.findWidget.clear(); + } + + private async attachModel(input: NotebookEditorInput, model: NotebookEditorModel) { + if (!this.webview) { + this.webview = new BackLayerWebView(this.webviewService, this.notebookService, this, this.environmentSerice); + this.list?.rowsContainer.insertAdjacentElement('afterbegin', this.webview!.element); + } + + this.notebookViewModel = this.instantiationService.createInstance(NotebookViewModel, input.viewType!, model); + const viewState = this.loadTextEditorViewState(input); + this.notebookViewModel.restoreEditorViewState(viewState); + + this.localStore.add(this.notebookViewModel.onDidChangeViewCells((e) => { + if (e.synchronous) { + e.splices.reverse().forEach((diff) => { + this.list?.splice(diff[0], diff[1], diff[2]); + }); + } else { + DOM.scheduleAtNextAnimationFrame(() => { + e.splices.reverse().forEach((diff) => { + this.list?.splice(diff[0], diff[1], diff[2]); + }); + }); + } + })); + + this.webview?.updateRendererPreloads(this.notebookViewModel.renderers); + + this.localStore.add(this.list!.onWillScroll(e => { + this.webview!.updateViewScrollTop(-e.scrollTop, []); + })); + + this.localStore.add(this.list!.onDidChangeContentHeight(() => { + const scrollTop = this.list?.scrollTop || 0; + const scrollHeight = this.list?.scrollHeight || 0; + this.webview!.element.style.height = `${scrollHeight}px`; + let updateItems: { cell: CellViewModel, output: IOutput, cellTop: number }[] = []; + + if (this.webview?.insetMapping) { + this.webview?.insetMapping.forEach((value, key) => { + let cell = value.cell; + let index = this.notebookViewModel!.getViewCellIndex(cell); + let cellTop = this.list?.getAbsoluteTop(index) || 0; + if (this.webview!.shouldUpdateInset(cell, key, cellTop)) { + updateItems.push({ + cell: cell, + output: key, + cellTop: cellTop + }); + } + }); + + if (updateItems.length) { + this.webview?.updateViewScrollTop(-scrollTop, updateItems); + } + } + })); + + this.localStore.add(this.list!.onDidChangeFocus((e) => { + if (e.elements.length > 0) { + this.notebookService.updateNotebookActiveCell(input.viewType!, input.resource!, e.elements[0].handle); + } + })); + + this.list?.splice(0, this.list?.length || 0); + this.list?.splice(0, 0, this.notebookViewModel!.viewCells as CellViewModel[]); + this.list?.layout(); + } + + private saveTextEditorViewState(input: NotebookEditorInput): void { + if (this.group && this.notebookViewModel) { + const state = this.notebookViewModel.saveEditorViewState(); + this.editorMemento.saveEditorState(this.group, input.resource, state); + } + } + + private loadTextEditorViewState(input: NotebookEditorInput): INotebookEditorViewState | undefined { + if (this.group) { + return this.editorMemento.loadEditorState(this.group, input.resource); + } + + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + layout(dimension: DOM.Dimension): void { + this.dimension = new DOM.Dimension(dimension.width, dimension.height); + DOM.toggleClass(this.rootElement, 'mid-width', dimension.width < 1000 && dimension.width >= 600); + DOM.toggleClass(this.rootElement, 'narrow-width', dimension.width < 600); + DOM.size(this.body, dimension.width, dimension.height); + this.list?.layout(dimension.height, dimension.width); + } + + protected saveState(): void { + if (this.input instanceof NotebookEditorInput) { + this.saveTextEditorViewState(this.input); + } + + super.saveState(); + } + + //#endregion + + //#region Editor Features + + selectElement(cell: ICellViewModel) { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.setSelection([index]); + this.list?.setFocus([index]); + } + } + + revealInView(cell: ICellViewModel) { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealInView(index); + } + } + + revealInCenterIfOutsideViewport(cell: ICellViewModel) { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealInCenterIfOutsideViewport(index); + } + } + + revealInCenter(cell: ICellViewModel) { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealInCenter(index); + } + } + + revealLineInView(cell: ICellViewModel, line: number): void { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealLineInView(index, line); + } + } + + revealLineInCenter(cell: ICellViewModel, line: number) { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealLineInCenter(index, line); + } + } + + revealLineInCenterIfOutsideViewport(cell: ICellViewModel, line: number) { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealLineInCenterIfOutsideViewport(index, line); + } + } + + revealRangeInView(cell: ICellViewModel, range: Range): void { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealRangeInView(index, range); + } + } + + revealRangeInCenter(cell: ICellViewModel, range: Range): void { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealRangeInCenter(index, range); + } + } + + revealRangeInCenterIfOutsideViewport(cell: ICellViewModel, range: Range): void { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.revealRangeInCenterIfOutsideViewport(index, range); + } + } + + setCellSelection(cell: ICellViewModel, range: Range): void { + const index = this.notebookViewModel?.getViewCellIndex(cell); + + if (index !== undefined) { + this.list?.setCellSelection(index, range); + } + } + + changeDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => any): any { + return this.notebookViewModel?.changeDecorations(callback); + } + + //#endregion + + //#region Find Delegate + + public showFind() { + this.findWidget.reveal(); + } + + public hideFind() { + this.findWidget.hide(); + this.focus(); + } + + //#endregion + + //#region Cell operations + layoutNotebookCell(cell: ICellViewModel, height: number) { + let relayout = (cell: ICellViewModel, height: number) => { + let index = this.notebookViewModel!.getViewCellIndex(cell); + if (index >= 0) { + this.list?.updateElementHeight(index, height); + } + }; + + DOM.scheduleAtNextAnimationFrame(() => { + relayout(cell, height); + }); + } + + async insertNotebookCell(cell: ICellViewModel, type: CellKind, direction: 'above' | 'below', initialText: string = ''): Promise { + const newLanguages = this.notebookViewModel!.languages; + const language = newLanguages && newLanguages.length ? newLanguages[0] : 'markdown'; + const index = this.notebookViewModel!.getViewCellIndex(cell); + const insertIndex = direction === 'above' ? index : index + 1; + const newModeCell = await this.notebookService.createNotebookCell(this.notebookViewModel!.viewType, this.notebookViewModel!.uri, insertIndex, language, type); + newModeCell!.source = initialText.split(/\r?\n/g); + const newCell = this.notebookViewModel!.insertCell(insertIndex, newModeCell!, true); + this.list?.setFocus([insertIndex]); + + if (type === CellKind.Markdown) { + newCell.state = CellState.Editing; + } + + DOM.scheduleAtNextAnimationFrame(() => { + this.list?.revealInCenterIfOutsideViewport(insertIndex); + }); + } + + async deleteNotebookCell(cell: ICellViewModel): Promise { + (cell as CellViewModel).save(); + const index = this.notebookViewModel!.getViewCellIndex(cell); + await this.notebookService.deleteNotebookCell(this.notebookViewModel!.viewType, this.notebookViewModel!.uri, index); + this.notebookViewModel!.deleteCell(index, true); + } + + moveCellDown(cell: ICellViewModel): void { + const index = this.notebookViewModel!.getViewCellIndex(cell); + const newIdx = index + 1; + this.moveCellToIndex(cell, index, newIdx); + } + + moveCellUp(cell: ICellViewModel): void { + const index = this.notebookViewModel!.getViewCellIndex(cell); + const newIdx = index - 1; + this.moveCellToIndex(cell, index, newIdx); + } + + private moveCellToIndex(cell: ICellViewModel, index: number, newIdx: number): void { + if (!this.notebookViewModel!.moveCellToIdx(index, newIdx, true)) { + return; + } + + DOM.scheduleAtNextAnimationFrame(() => { + this.list?.revealInCenterIfOutsideViewport(index + 1); + }); + } + + editNotebookCell(cell: CellViewModel): void { + cell.state = CellState.Editing; + + this.renderedEditors.get(cell)?.focus(); + } + + saveNotebookCell(cell: ICellViewModel): void { + cell.state = CellState.Preview; + } + + getActiveCell() { + let elements = this.list?.getFocusedElements(); + + if (elements && elements.length) { + return elements[0]; + } + + return undefined; + } + + focusNotebookCell(cell: ICellViewModel, focusEditor: boolean) { + const index = this.notebookViewModel!.getViewCellIndex(cell); + + if (focusEditor) { + this.list?.setFocus([index]); + this.list?.setSelection([index]); + this.list?.focusView(); + + cell.state = CellState.Editing; + cell.focusMode = CellFocusMode.Editor; + this.revealInCenterIfOutsideViewport(cell); + } else { + let itemDOM = this.list?.domElementAtIndex(index); + if (document.activeElement && itemDOM && itemDOM.contains(document.activeElement)) { + (document.activeElement as HTMLElement).blur(); + } + + cell.state = CellState.Preview; + cell.focusMode = CellFocusMode.Editor; + + this.list?.setFocus([index]); + this.list?.setSelection([index]); + this.revealInCenterIfOutsideViewport(cell); + this.list?.focusView(); + } + } + + //#endregion + + //#region MISC + + getLayoutInfo(): NotebookLayoutInfo { + if (!this.list) { + throw new Error('Editor is not initalized successfully'); + } + + return { + width: this.dimension!.width, + height: this.dimension!.height, + fontInfo: this.fontInfo! + }; + } + getFontInfo(): BareFontInfo | undefined { + return this.fontInfo; + } + + triggerScroll(event: IMouseWheelEvent) { + this.list?.triggerScrollFromMouseWheelEvent(event); + } + + createInset(cell: CellViewModel, output: IOutput, shadowContent: string, offset: number) { + if (!this.webview) { + return; + } + + let preloads = this.notebookViewModel!.renderers; + + if (!this.webview!.insetMapping.has(output)) { + let index = this.notebookViewModel!.getViewCellIndex(cell); + let cellTop = this.list?.getAbsoluteTop(index) || 0; + + this.webview!.createInset(cell, output, cellTop, offset, shadowContent, preloads); + } else { + let index = this.notebookViewModel!.getViewCellIndex(cell); + let cellTop = this.list?.getAbsoluteTop(index) || 0; + let scrollTop = this.list?.scrollTop || 0; + + this.webview!.updateViewScrollTop(-scrollTop, [{ cell: cell, output: output, cellTop: cellTop }]); + } + } + + removeInset(output: IOutput) { + if (!this.webview) { + return; + } + + this.webview!.removeInset(output); + } + + getOutputRenderer(): OutputRenderer { + return this.outputRenderer; + } + + //#endregion +} + +const embeddedEditorBackground = 'walkThrough.embeddedEditorBackground'; + +export const focusedCellIndicator = registerColor('notebook.focusedCellIndicator', { + light: new Color(new RGBA(102, 175, 224)), + dark: new Color(new RGBA(12, 125, 157)), + hc: new Color(new RGBA(0, 73, 122)) +}, nls.localize('notebook.focusedCellIndicator', "The color of the focused notebook cell indicator.")); + + +registerThemingParticipant((theme, collector) => { + const color = getExtraColor(theme, embeddedEditorBackground, { dark: 'rgba(0, 0, 0, .4)', extra_dark: 'rgba(200, 235, 255, .064)', light: '#f4f4f4', hc: null }); + if (color) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .cell .monaco-editor-background, + .monaco-workbench .part.editor > .content .notebook-editor .cell .margin-view-overlays { background: ${color}; }`); + } + const link = theme.getColor(textLinkForeground); + if (link) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .cell a { color: ${link}; }`); + } + const activeLink = theme.getColor(textLinkActiveForeground); + if (activeLink) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .cell a:hover, + .monaco-workbench .part.editor > .content .notebook-editor .cell a:active { color: ${activeLink}; }`); + } + const shortcut = theme.getColor(textPreformatForeground); + if (shortcut) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor code, + .monaco-workbench .part.editor > .content .notebook-editor .shortcut { color: ${shortcut}; }`); + } + const border = theme.getColor(contrastBorder); + if (border) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .monaco-editor { border-color: ${border}; }`); + } + const quoteBackground = theme.getColor(textBlockQuoteBackground); + if (quoteBackground) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor blockquote { background: ${quoteBackground}; }`); + } + const quoteBorder = theme.getColor(textBlockQuoteBorder); + if (quoteBorder) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor blockquote { border-color: ${quoteBorder}; }`); + } + + const inactiveListItem = theme.getColor('list.inactiveSelectionBackground'); + + if (inactiveListItem) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .output { background-color: ${inactiveListItem}; }`); + } + + const focusedCellIndicatorColor = theme.getColor(focusedCellIndicator); + if (focusedCellIndicatorColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row.focused .notebook-cell-focus-indicator { border-color: ${focusedCellIndicatorColor}; }`); + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row.selected .notebook-cell-focus-indicator { border-color: ${focusedCellIndicatorColor}; }`); + } + + // Cell Margin + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .monaco-list-row > div.cell { padding: 8px ${CELL_MARGIN}px 8px ${CELL_MARGIN}px; }`); + collector.addRule(`.monaco-workbench .part.editor > .content .notebook-editor .output { margin: 8px ${CELL_MARGIN}px; }`); +}); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorInput.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorInput.ts new file mode 100644 index 0000000000..5d99148855 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorInput.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorInput, EditorModel, IEditorInput, GroupIdentifier, ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor'; +import { Emitter, Event } from 'vs/base/common/event'; +import { INotebookService } from 'vs/workbench/contrib/notebook/browser/notebookService'; +import { ICell, NotebookCellsSplice } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { URI } from 'vs/base/common/uri'; +import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; +import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; + +export class NotebookEditorModel extends EditorModel { + private _dirty = false; + + protected readonly _onDidChangeDirty = this._register(new Emitter()); + readonly onDidChangeDirty = this._onDidChangeDirty.event; + + private readonly _onDidChangeCells = new Emitter(); + get onDidChangeCells(): Event { return this._onDidChangeCells.event; } + + + get notebook() { + return this._notebook; + } + + constructor( + private _notebook: NotebookTextModel + ) { + super(); + + if (_notebook && _notebook.onDidChangeCells) { + this._register(_notebook.onDidChangeContent(() => { + this._dirty = true; + this._onDidChangeDirty.fire(); + })); + this._register(_notebook.onDidChangeCells((e) => { + this._onDidChangeCells.fire(e); + })); + } + } + + isDirty() { + return this._dirty; + } + + getNotebook(): NotebookTextModel { + return this._notebook; + } + + insertCell(cell: ICell, index: number) { + let notebook = this.getNotebook(); + + if (notebook) { + let mainCell = new NotebookCellTextModel(URI.revive(cell.uri), cell.handle, cell.source, cell.language, cell.cellKind, cell.outputs); + this.notebook.insertNewCell(index, mainCell); + this._dirty = true; + this._onDidChangeDirty.fire(); + + } + } + + deleteCell(index: number) { + let notebook = this.getNotebook(); + + if (notebook) { + this.notebook.removeCell(index); + } + } + + async save(): Promise { + if (this._notebook) { + this._dirty = false; + this._onDidChangeDirty.fire(); + // todo, flush all states + return true; + } + + return false; + } +} + +export class NotebookEditorInput extends EditorInput { + static readonly ID: string = 'workbench.input.notebook'; + private promise: Promise | null = null; + private textModel: NotebookEditorModel | null = null; + + constructor( + public resource: URI, + public name: string, + public readonly viewType: string | undefined, + @INotebookService private readonly notebookService: INotebookService + ) { + super(); + } + + getTypeId(): string { + return NotebookEditorInput.ID; + } + + getName(): string { + return this.name; + } + + isDirty() { + return this.textModel?.isDirty() || false; + } + + async save(group: GroupIdentifier, options?: ISaveOptions): Promise { + if (this.textModel) { + await this.notebookService.save(this.textModel.notebook.viewType, this.textModel.notebook.uri); + await this.textModel.save(); + return this; + } + + return undefined; + } + + async revert(group: GroupIdentifier, options?: IRevertOptions): Promise { + if (this.textModel) { + // TODO@rebornix we need hashing + await this.textModel.save(); + } + } + + async resolve(): Promise { + if (!this.promise) { + await this.notebookService.canResolve(this.viewType!); + + this.promise = this.notebookService.resolveNotebook(this.viewType!, this.resource).then(notebook => { + this.textModel = new NotebookEditorModel(notebook!); + this.textModel.onDidChangeDirty(() => this._onDidChangeDirty.fire()); + return this.textModel; + }); + } + + return this.promise; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebookRegistry.ts b/src/vs/workbench/contrib/notebook/browser/notebookRegistry.ts new file mode 100644 index 0000000000..7c27fa6863 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookRegistry.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CellOutputKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { BrandedService, IConstructorSignature1 } from 'vs/platform/instantiation/common/instantiation'; +import { INotebookEditor, IOutputTransformContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; + +export type IOutputTransformCtor = IConstructorSignature1; + +export interface IOutputTransformDescription { + id: string; + kind: CellOutputKind; + ctor: IOutputTransformCtor; +} + +export namespace NotebookRegistry { + export function getOutputTransformContributions(): IOutputTransformDescription[] { + return NotebookRegistryImpl.INSTANCE.getNotebookOutputTransform(); + } +} + +export function registerOutputTransform(id: string, kind: CellOutputKind, ctor: { new(editor: INotebookEditor, ...services: Services): IOutputTransformContribution }): void { + NotebookRegistryImpl.INSTANCE.registerOutputTransform(id, kind, ctor); +} + +class NotebookRegistryImpl { + + static readonly INSTANCE = new NotebookRegistryImpl(); + + private readonly outputTransforms: IOutputTransformDescription[]; + + constructor() { + this.outputTransforms = []; + } + + registerOutputTransform(id: string, kind: CellOutputKind, ctor: { new(editor: INotebookEditor, ...services: Services): IOutputTransformContribution }): void { + this.outputTransforms.push({ id: id, kind: kind, ctor: ctor as IOutputTransformCtor }); + } + + getNotebookOutputTransform(): IOutputTransformDescription[] { + return this.outputTransforms.slice(0); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebookService.ts b/src/vs/workbench/contrib/notebook/browser/notebookService.ts new file mode 100644 index 0000000000..35e753cf25 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookService.ts @@ -0,0 +1,337 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { URI } from 'vs/base/common/uri'; +import { notebookProviderExtensionPoint, notebookRendererExtensionPoint } from 'vs/workbench/contrib/notebook/browser/extensionPoint'; +import { NotebookProviderInfo } from 'vs/workbench/contrib/notebook/common/notebookProvider'; +import { NotebookExtensionDescription } from 'vs/workbench/api/common/extHost.protocol'; +import { Emitter, Event } from 'vs/base/common/event'; +import { INotebookTextModel, ICell, INotebookMimeTypeSelector, INotebookRendererInfo, CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { NotebookOutputRendererInfo } from 'vs/workbench/contrib/notebook/common/notebookOutputRenderer'; +import { Iterable } from 'vs/base/common/iterator'; +import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; +import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; + +function MODEL_ID(resource: URI): string { + return resource.toString(); +} + +export const INotebookService = createDecorator('notebookService'); + +export interface IMainNotebookController { + resolveNotebook(viewType: string, uri: URI): Promise; + executeNotebook(viewType: string, uri: URI): Promise; + updateNotebookActiveCell(uri: URI, cellHandle: number): void; + createRawCell(uri: URI, index: number, language: string, type: CellKind): Promise; + deleteCell(uri: URI, index: number): Promise + executeNotebookActiveCell(uri: URI): void; + destoryNotebookDocument(notebook: INotebookTextModel): Promise; + save(uri: URI): Promise; +} + +export interface INotebookService { + _serviceBrand: undefined; + canResolve(viewType: string): Promise; + onDidChangeActiveEditor: Event<{ viewType: string, uri: URI }>; + registerNotebookController(viewType: string, extensionData: NotebookExtensionDescription, controller: IMainNotebookController): void; + unregisterNotebookProvider(viewType: string): void; + registerNotebookRenderer(handle: number, extensionData: NotebookExtensionDescription, type: string, selectors: INotebookMimeTypeSelector, preloads: URI[]): void; + unregisterNotebookRenderer(handle: number): void; + getRendererInfo(handle: number): INotebookRendererInfo | undefined; + resolveNotebook(viewType: string, uri: URI): Promise; + executeNotebook(viewType: string, uri: URI): Promise; + executeNotebookActiveCell(viewType: string, uri: URI): Promise; + getContributedNotebookProviders(resource: URI): readonly NotebookProviderInfo[]; + getNotebookProviderResourceRoots(): URI[]; + updateNotebookActiveCell(viewType: string, resource: URI, cellHandle: number): void; + createNotebookCell(viewType: string, resource: URI, index: number, language: string, type: CellKind): Promise; + deleteNotebookCell(viewType: string, resource: URI, index: number): Promise; + destoryNotebookDocument(viewType: string, notebook: INotebookTextModel): void; + updateActiveNotebookDocument(viewType: string, resource: URI): void; + save(viewType: string, resource: URI): Promise; +} + +export class NotebookProviderInfoStore { + private readonly contributedEditors = new Map(); + + clear() { + this.contributedEditors.clear(); + } + + get(viewType: string): NotebookProviderInfo | undefined { + return this.contributedEditors.get(viewType); + } + + add(info: NotebookProviderInfo): void { + if (this.contributedEditors.has(info.id)) { + console.log(`Custom editor with id '${info.id}' already registered`); + return; + } + this.contributedEditors.set(info.id, info); + } + + getContributedNotebook(resource: URI): readonly NotebookProviderInfo[] { + return [...Iterable.filter(this.contributedEditors.values(), customEditor => customEditor.matches(resource))]; + } +} + +export class NotebookOutputRendererInfoStore { + private readonly contributedRenderers = new Map(); + + clear() { + this.contributedRenderers.clear(); + } + + get(viewType: string): NotebookOutputRendererInfo | undefined { + return this.contributedRenderers.get(viewType); + } + + add(info: NotebookOutputRendererInfo): void { + if (this.contributedRenderers.has(info.id)) { + console.log(`Custom notebook output renderer with id '${info.id}' already registered`); + return; + } + this.contributedRenderers.set(info.id, info); + } + + getContributedRenderer(mimeType: string): readonly NotebookOutputRendererInfo[] { + return Array.from(this.contributedRenderers.values()).filter(customEditor => + customEditor.matches(mimeType)); + } +} + +class ModelData implements IDisposable { + private readonly _modelEventListeners = new DisposableStore(); + + constructor( + public model: NotebookTextModel, + onWillDispose: (model: INotebookTextModel) => void + ) { + this._modelEventListeners.add(model.onWillDispose(() => onWillDispose(model))); + } + + dispose(): void { + this._modelEventListeners.dispose(); + } +} + + +export class NotebookService extends Disposable implements INotebookService { + _serviceBrand: undefined; + private readonly _notebookProviders = new Map(); + private readonly _notebookRenderers = new Map(); + notebookProviderInfoStore: NotebookProviderInfoStore = new NotebookProviderInfoStore(); + notebookRenderersInfoStore: NotebookOutputRendererInfoStore = new NotebookOutputRendererInfoStore(); + private readonly _models: { [modelId: string]: ModelData; }; + private _onDidChangeActiveEditor = new Emitter<{ viewType: string, uri: URI }>(); + onDidChangeActiveEditor: Event<{ viewType: string, uri: URI }> = this._onDidChangeActiveEditor.event; + private _resolvePool = new Map void>(); + + constructor( + @IExtensionService private readonly extensionService: IExtensionService + ) { + super(); + + this._models = {}; + notebookProviderExtensionPoint.setHandler((extensions) => { + this.notebookProviderInfoStore.clear(); + + for (const extension of extensions) { + for (const notebookContribution of extension.value) { + this.notebookProviderInfoStore.add(new NotebookProviderInfo({ + id: notebookContribution.viewType, + displayName: notebookContribution.displayName, + selector: notebookContribution.selector || [], + })); + } + } + // console.log(this._notebookProviderInfoStore); + }); + + notebookRendererExtensionPoint.setHandler((renderers) => { + this.notebookRenderersInfoStore.clear(); + + for (const extension of renderers) { + for (const notebookContribution of extension.value) { + this.notebookRenderersInfoStore.add(new NotebookOutputRendererInfo({ + id: notebookContribution.viewType, + displayName: notebookContribution.displayName, + mimeTypes: notebookContribution.mimeTypes || [] + })); + } + } + + // console.log(this.notebookRenderersInfoStore); + }); + } + + async canResolve(viewType: string): Promise { + if (this._notebookProviders.has(viewType)) { + return; + } + + this.extensionService.activateByEvent(`onNotebookEditor:${viewType}`); + + let resolve: () => void; + const promise = new Promise(r => { resolve = r; }); + this._resolvePool.set(viewType, resolve!); + return promise; + } + + registerNotebookController(viewType: string, extensionData: NotebookExtensionDescription, controller: IMainNotebookController) { + this._notebookProviders.set(viewType, { extensionData, controller }); + + let resolve = this._resolvePool.get(viewType); + if (resolve) { + resolve(); + this._resolvePool.delete(viewType); + } + } + + unregisterNotebookProvider(viewType: string): void { + this._notebookProviders.delete(viewType); + } + + registerNotebookRenderer(handle: number, extensionData: NotebookExtensionDescription, type: string, selectors: INotebookMimeTypeSelector, preloads: URI[]) { + this._notebookRenderers.set(handle, { extensionData, type, selectors, preloads }); + } + + unregisterNotebookRenderer(handle: number) { + this._notebookRenderers.delete(handle); + } + + getRendererInfo(handle: number): INotebookRendererInfo | undefined { + const renderer = this._notebookRenderers.get(handle); + + if (renderer) { + return { + id: renderer.extensionData.id, + extensionLocation: URI.revive(renderer.extensionData.location), + preloads: renderer.preloads + }; + } + + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + async resolveNotebook(viewType: string, uri: URI): Promise { + const provider = this._notebookProviders.get(viewType); + if (!provider) { + return undefined; + } + + const notebookModel = await provider.controller.resolveNotebook(viewType, uri); + if (!notebookModel) { + return undefined; + } + + // new notebook model created + const modelId = MODEL_ID(uri); + const modelData = new ModelData( + notebookModel, + (model) => this._onWillDispose(model), + ); + this._models[modelId] = modelData; + return modelData.model; + } + + updateNotebookActiveCell(viewType: string, resource: URI, cellHandle: number): void { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + provider.controller.updateNotebookActiveCell(resource, cellHandle); + } + } + + async createNotebookCell(viewType: string, resource: URI, index: number, language: string, type: CellKind): Promise { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + return provider.controller.createRawCell(resource, index, language, type); + } + + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + + async deleteNotebookCell(viewType: string, resource: URI, index: number): Promise { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + return provider.controller.deleteCell(resource, index); + } + + return false; + } + + async executeNotebook(viewType: string, uri: URI): Promise { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + return provider.controller.executeNotebook(viewType, uri); + } + + return; + } + + async executeNotebookActiveCell(viewType: string, uri: URI): Promise { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + await provider.controller.executeNotebookActiveCell(uri); + } + } + + getContributedNotebookProviders(resource: URI): readonly NotebookProviderInfo[] { + return this.notebookProviderInfoStore.getContributedNotebook(resource); + } + + getContributedNotebookOutputRenderers(mimeType: string): readonly NotebookOutputRendererInfo[] { + return this.notebookRenderersInfoStore.getContributedRenderer(mimeType); + } + + getNotebookProviderResourceRoots(): URI[] { + let ret: URI[] = []; + this._notebookProviders.forEach(val => { + ret.push(URI.revive(val.extensionData.location)); + }); + + return ret; + } + + destoryNotebookDocument(viewType: string, notebook: INotebookTextModel): void { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + provider.controller.destoryNotebookDocument(notebook); + } + } + + updateActiveNotebookDocument(viewType: string, resource: URI): void { + this._onDidChangeActiveEditor.fire({ viewType, uri: resource }); + } + + async save(viewType: string, resource: URI): Promise { + let provider = this._notebookProviders.get(viewType); + + if (provider) { + return provider.controller.save(resource); + } + + return false; + } + + private _onWillDispose(model: INotebookTextModel): void { + let modelId = MODEL_ID(model.uri); + let modelData = this._models[modelId]; + + delete this._models[modelId]; + modelData?.dispose(); + + // this._onModelRemoved.fire(model); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts new file mode 100644 index 0000000000..4f0f1bba5a --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts @@ -0,0 +1,362 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from 'vs/base/browser/dom'; +import { IListRenderer, IListVirtualDelegate, ListError } from 'vs/base/browser/ui/list/list'; +import { Event } from 'vs/base/common/event'; +import { ScrollEvent } from 'vs/base/common/scrollable'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IListService, IWorkbenchListOptions, WorkbenchList } from 'vs/platform/list/browser/listService'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; +import { isMacintosh } from 'vs/base/common/platform'; +import { NOTEBOOK_EDITOR_CURSOR_BOUNDARY } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { Range } from 'vs/editor/common/core/range'; +import { CellRevealType, CellRevealPosition, CursorAtBoundary } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; + +export class NotebookCellList extends WorkbenchList implements IDisposable { + get onWillScroll(): Event { return this.view.onWillScroll; } + + get rowsContainer(): HTMLElement { + return this.view.containerDomNode; + } + private _previousSelectedElements: CellViewModel[] = []; + private _localDisposableStore = new DisposableStore(); + + constructor( + private listUser: string, + container: HTMLElement, + delegate: IListVirtualDelegate, + renderers: IListRenderer[], + contextKeyService: IContextKeyService, + options: IWorkbenchListOptions, + @IListService listService: IListService, + @IThemeService themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IKeybindingService keybindingService: IKeybindingService + + ) { + super(listUser, container, delegate, renderers, options, contextKeyService, listService, themeService, configurationService, keybindingService); + + this._previousSelectedElements = this.getSelectedElements(); + this._localDisposableStore.add(this.onDidChangeSelection((e) => { + this._previousSelectedElements.forEach(element => { + if (e.elements.indexOf(element) < 0) { + element.onDeselect(); + } + }); + this._previousSelectedElements = e.elements; + })); + + const notebookEditorCursorAtBoundaryContext = NOTEBOOK_EDITOR_CURSOR_BOUNDARY.bindTo(contextKeyService); + notebookEditorCursorAtBoundaryContext.set('none'); + + let cursorSelectionListener: IDisposable | null = null; + let textEditorAttachListener: IDisposable | null = null; + + const recomputeContext = (element: CellViewModel) => { + switch (element.cursorAtBoundary()) { + case CursorAtBoundary.Both: + notebookEditorCursorAtBoundaryContext.set('both'); + break; + case CursorAtBoundary.Top: + notebookEditorCursorAtBoundaryContext.set('top'); + break; + case CursorAtBoundary.Bottom: + notebookEditorCursorAtBoundaryContext.set('bottom'); + break; + default: + notebookEditorCursorAtBoundaryContext.set('none'); + break; + } + return; + }; + + // Cursor Boundary context + this._localDisposableStore.add(this.onDidChangeSelection((e) => { + if (e.elements.length) { + cursorSelectionListener?.dispose(); + textEditorAttachListener?.dispose(); + // we only validate the first focused element + const focusedElement = e.elements[0]; + + cursorSelectionListener = focusedElement.onDidChangeCursorSelection(() => { + recomputeContext(focusedElement); + }); + + textEditorAttachListener = focusedElement.onDidChangeEditorAttachState(() => { + if (focusedElement.editorAttached) { + recomputeContext(focusedElement); + } + }); + + recomputeContext(focusedElement); + return; + } + + // reset context + notebookEditorCursorAtBoundaryContext.set('none'); + })); + + } + + domElementAtIndex(index: number): HTMLElement | null { + return this.view.domElement(index); + } + + focusView() { + this.view.domNode.focus(); + } + + getAbsoluteTop(index: number): number { + if (index < 0 || index >= this.length) { + throw new ListError(this.listUser, `Invalid index ${index}`); + } + + return this.view.elementTop(index); + } + + triggerScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent) { + this.view.triggerScrollFromMouseWheelEvent(browserEvent); + } + + updateElementHeight(index: number, size: number): void { + const focused = this.getSelection(); + this.view.updateElementHeight(index, size, focused.length ? focused[0] : null); + // this.view.updateElementHeight(index, size, null); + } + + // override + domFocus() { + if (document.activeElement && this.view.domNode.contains(document.activeElement)) { + // for example, when focus goes into monaco editor, if we refocus the list view, the editor will lose focus. + return; + } + + if (!isMacintosh && document.activeElement && isContextMenuFocused()) { + return; + } + + super.domFocus(); + } + + private _revealRange(index: number, range: Range, revealType: CellRevealType, newlyCreated: boolean, alignToBottom: boolean) { + const element = this.view.element(index); + const scrollTop = this.view.getScrollTop(); + const wrapperBottom = scrollTop + this.view.renderHeight; + const startLineNumber = range.startLineNumber; + const lineOffset = element.getLineScrollTopOffset(startLineNumber); + const elementTop = this.view.elementTop(index); + const lineTop = elementTop + lineOffset; + + // TODO@rebornix 30 ---> line height * 1.5 + if (lineTop < scrollTop) { + this.view.setScrollTop(lineTop - 30); + } else if (lineTop > wrapperBottom) { + this.view.setScrollTop(scrollTop + lineTop - wrapperBottom + 30); + } else if (newlyCreated) { + // newly scrolled into view + if (alignToBottom) { + // align to the bottom + this.view.setScrollTop(scrollTop + lineTop - wrapperBottom + 30); + } else { + // align to to top + this.view.setScrollTop(lineTop - 30); + } + } + + if (revealType === CellRevealType.Range) { + element.revealRangeInCenter(range); + } + } + + // TODO@rebornix TEST & Fix potential bugs + // List items have real dynamic heights, which means after we set `scrollTop` based on the `elementTop(index)`, the element at `index` might still be removed from the view once all relayouting tasks are done. + // For example, we scroll item 10 into the view upwards, in the first round, items 7, 8, 9, 10 are all in the viewport. Then item 7 and 8 resize themselves to be larger and finally item 10 is removed from the view. + // To ensure that item 10 is always there, we need to scroll item 10 to the top edge of the viewport. + private _revealRangeInternal(index: number, range: Range, revealType: CellRevealType) { + const scrollTop = this.view.getScrollTop(); + const wrapperBottom = scrollTop + this.view.renderHeight; + const elementTop = this.view.elementTop(index); + const element = this.view.element(index); + + if (element.editorAttached) { + this._revealRange(index, range, revealType, false, false); + } else { + const elementHeight = this.view.elementHeight(index); + let upwards = false; + + if (elementTop + elementHeight < scrollTop) { + // scroll downwards + this.view.setScrollTop(elementTop); + upwards = false; + } else if (elementTop > wrapperBottom) { + // scroll upwards + this.view.setScrollTop(elementTop - this.view.renderHeight / 2); + upwards = true; + } + + const editorAttachedPromise = new Promise((resolve, reject) => { + element.onDidChangeEditorAttachState(state => state ? resolve() : reject()); + }); + + editorAttachedPromise.then(() => { + this._revealRange(index, range, revealType, true, upwards); + }); + } + } + + revealLineInView(index: number, line: number) { + this._revealRangeInternal(index, new Range(line, 1, line, 1), CellRevealType.Line); + } + + revealRangeInView(index: number, range: Range): void { + this._revealRangeInternal(index, range, CellRevealType.Range); + } + + private _revealRangeInCenterInternal(index: number, range: Range, revealType: CellRevealType) { + const reveal = (index: number, range: Range, revealType: CellRevealType) => { + const element = this.view.element(index); + let lineOffset = element.getLineScrollTopOffset(range.startLineNumber); + let lineOffsetInView = this.view.elementTop(index) + lineOffset; + this.view.setScrollTop(lineOffsetInView - this.view.renderHeight / 2); + + if (revealType === CellRevealType.Range) { + element.revealRangeInCenter(range); + } + }; + + const elementTop = this.view.elementTop(index); + const viewItemOffset = elementTop; + this.view.setScrollTop(viewItemOffset - this.view.renderHeight / 2); + const element = this.view.element(index); + + if (!element.editorAttached) { + getEditorAttachedPromise(element).then(() => reveal(index, range, revealType)); + } else { + reveal(index, range, revealType); + } + } + + revealLineInCenter(index: number, line: number) { + this._revealRangeInCenterInternal(index, new Range(line, 1, line, 1), CellRevealType.Line); + } + + revealRangeInCenter(index: number, range: Range): void { + this._revealRangeInCenterInternal(index, range, CellRevealType.Range); + } + + private _revealRangeInCenterIfOutsideViewportInternal(index: number, range: Range, revealType: CellRevealType) { + const reveal = (index: number, range: Range, revealType: CellRevealType) => { + const element = this.view.element(index); + let lineOffset = element.getLineScrollTopOffset(range.startLineNumber); + let lineOffsetInView = this.view.elementTop(index) + lineOffset; + this.view.setScrollTop(lineOffsetInView - this.view.renderHeight / 2); + + if (revealType === CellRevealType.Range) { + setTimeout(() => { + element.revealRangeInCenter(range); + }, 240); + } + }; + + const scrollTop = this.view.getScrollTop(); + const wrapperBottom = scrollTop + this.view.renderHeight; + const elementTop = this.view.elementTop(index); + const viewItemOffset = elementTop; + const element = this.view.element(index); + + if (viewItemOffset < scrollTop || viewItemOffset > wrapperBottom) { + // let it render + this.view.setScrollTop(viewItemOffset - this.view.renderHeight / 2); + + // after rendering, it might be pushed down due to markdown cell dynamic height + const elementTop = this.view.elementTop(index); + this.view.setScrollTop(elementTop - this.view.renderHeight / 2); + + // reveal editor + if (!element.editorAttached) { + getEditorAttachedPromise(element).then(() => reveal(index, range, revealType)); + } else { + // for example markdown + } + } else { + if (element.editorAttached) { + element.revealRangeInCenter(range); + } else { + // for example, markdown cell in preview mode + getEditorAttachedPromise(element).then(() => reveal(index, range, revealType)); + } + } + } + + revealLineInCenterIfOutsideViewport(index: number, line: number) { + this._revealRangeInCenterIfOutsideViewportInternal(index, new Range(line, 1, line, 1), CellRevealType.Line); + } + + revealRangeInCenterIfOutsideViewport(index: number, range: Range): void { + this._revealRangeInCenterIfOutsideViewportInternal(index, range, CellRevealType.Range); + } + + private _revealInternal(index: number, ignoreIfInsideViewport: boolean, revealPosition: CellRevealPosition) { + const scrollTop = this.view.getScrollTop(); + const wrapperBottom = scrollTop + this.view.renderHeight; + const elementTop = this.view.elementTop(index); + + if (ignoreIfInsideViewport && elementTop >= scrollTop && elementTop < wrapperBottom) { + // inside the viewport + return; + } + + // first render + const viewItemOffset = revealPosition === CellRevealPosition.Top ? elementTop : (elementTop - this.view.renderHeight / 2); + this.view.setScrollTop(viewItemOffset); + + // second scroll as markdown cell is dynamic + const newElementTop = this.view.elementTop(index); + const newViewItemOffset = revealPosition === CellRevealPosition.Top ? newElementTop : (newElementTop - this.view.renderHeight / 2); + this.view.setScrollTop(newViewItemOffset); + } + + revealInView(index: number) { + this._revealInternal(index, true, CellRevealPosition.Top); + } + + revealInCenter(index: number) { + this._revealInternal(index, false, CellRevealPosition.Center); + } + + revealInCenterIfOutsideViewport(index: number) { + this._revealInternal(index, true, CellRevealPosition.Center); + } + + setCellSelection(index: number, range: Range) { + const element = this.view.element(index); + if (element.editorAttached) { + element.setSelection(range); + } else { + getEditorAttachedPromise(element).then(() => { element.setSelection(range); }); + } + } + + dispose() { + this._localDisposableStore.dispose(); + super.dispose(); + } +} + +function getEditorAttachedPromise(element: CellViewModel) { + return new Promise((resolve, reject) => { + Event.once(element.onDidChangeEditorAttachState)(state => state ? resolve() : reject()); + }); +} + +function isContextMenuFocused() { + return !!DOM.findParentWithClass(document.activeElement, 'context-view'); +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/output/outputRenderer.ts b/src/vs/workbench/contrib/notebook/browser/view/output/outputRenderer.ts new file mode 100644 index 0000000000..4089d6b90d --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/output/outputRenderer.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IOutput, IRenderOutput } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { NotebookRegistry } from 'vs/workbench/contrib/notebook/browser/notebookRegistry'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { INotebookEditor, IOutputTransformContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; + +export class OutputRenderer { + protected readonly _contributions: { [key: string]: IOutputTransformContribution; }; + protected readonly _mimeTypeMapping: { [key: number]: IOutputTransformContribution; }; + + constructor( + notebookEditor: INotebookEditor, + private readonly instantiationService: IInstantiationService + ) { + this._contributions = {}; + this._mimeTypeMapping = {}; + + let contributions = NotebookRegistry.getOutputTransformContributions(); + + for (const desc of contributions) { + try { + const contribution = this.instantiationService.createInstance(desc.ctor, notebookEditor); + this._contributions[desc.id] = contribution; + this._mimeTypeMapping[desc.kind] = contribution; + } catch (err) { + onUnexpectedError(err); + } + } + } + + renderNoop(output: IOutput, container: HTMLElement): IRenderOutput { + const contentNode = document.createElement('p'); + + contentNode.innerText = `No renderer could be found for output. It has the following output type: ${output.outputKind}`; + container.appendChild(contentNode); + return { + hasDynamicHeight: false + }; + } + + render(output: IOutput, container: HTMLElement, preferredMimeType: string | undefined): IRenderOutput { + let transform = this._mimeTypeMapping[output.outputKind]; + + if (transform) { + return transform.render(output, container, preferredMimeType); + } else { + return this.renderNoop(output, container); + } + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/output/transforms/errorTransform.ts b/src/vs/workbench/contrib/notebook/browser/view/output/transforms/errorTransform.ts new file mode 100644 index 0000000000..809cabd5bf --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/output/transforms/errorTransform.ts @@ -0,0 +1,386 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRenderOutput, CellOutputKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { registerOutputTransform } from 'vs/workbench/contrib/notebook/browser/notebookRegistry'; +import * as DOM from 'vs/base/browser/dom'; +import { RGBA, Color } from 'vs/base/common/color'; +import { ansiColorIdentifiers } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { INotebookEditor, IOutputTransformContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; + +class ErrorTransform implements IOutputTransformContribution { + constructor( + public editor: INotebookEditor, + @IThemeService private readonly themeService: IThemeService + ) { + } + + render(output: any, container: HTMLElement): IRenderOutput { + const traceback = document.createElement('pre'); + DOM.addClasses(traceback, 'traceback'); + if (output.traceback) { + for (let j = 0; j < output.traceback.length; j++) { + traceback.appendChild(handleANSIOutput(output.traceback[j], this.themeService)); + } + } + container.appendChild(traceback); + return { + hasDynamicHeight: false + }; + } + + dispose(): void { + } +} + +registerOutputTransform('notebook.output.error', CellOutputKind.Error, ErrorTransform); + +/** + * @param text The content to stylize. + * @returns An {@link HTMLSpanElement} that contains the potentially stylized text. + */ +export function handleANSIOutput(text: string, themeService: IThemeService): HTMLSpanElement { + + const root: HTMLSpanElement = document.createElement('span'); + const textLength: number = text.length; + + let styleNames: string[] = []; + let customFgColor: RGBA | undefined; + let customBgColor: RGBA | undefined; + let currentPos: number = 0; + let buffer: string = ''; + + while (currentPos < textLength) { + + let sequenceFound: boolean = false; + + // Potentially an ANSI escape sequence. + // See http://ascii-table.com/ansi-escape-sequences.php & https://en.wikipedia.org/wiki/ANSI_escape_code + if (text.charCodeAt(currentPos) === 27 && text.charAt(currentPos + 1) === '[') { + + const startPos: number = currentPos; + currentPos += 2; // Ignore 'Esc[' as it's in every sequence. + + let ansiSequence: string = ''; + + while (currentPos < textLength) { + const char: string = text.charAt(currentPos); + ansiSequence += char; + + currentPos++; + + // Look for a known sequence terminating character. + if (char.match(/^[ABCDHIJKfhmpsu]$/)) { + sequenceFound = true; + break; + } + + } + + if (sequenceFound) { + + // Flush buffer with previous styles. + appendStylizedStringToContainer(root, buffer, styleNames, customFgColor, customBgColor); + + buffer = ''; + + /* + * Certain ranges that are matched here do not contain real graphics rendition sequences. For + * the sake of having a simpler expression, they have been included anyway. + */ + if (ansiSequence.match(/^(?:[34][0-8]|9[0-7]|10[0-7]|[013]|4|[34]9)(?:;[349][0-7]|10[0-7]|[013]|[245]|[34]9)?(?:;[012]?[0-9]?[0-9])*;?m$/)) { + + const styleCodes: number[] = ansiSequence.slice(0, -1) // Remove final 'm' character. + .split(';') // Separate style codes. + .filter(elem => elem !== '') // Filter empty elems as '34;m' -> ['34', '']. + .map(elem => parseInt(elem, 10)); // Convert to numbers. + + if (styleCodes[0] === 38 || styleCodes[0] === 48) { + // Advanced color code - can't be combined with formatting codes like simple colors can + // Ignores invalid colors and additional info beyond what is necessary + const colorType = (styleCodes[0] === 38) ? 'foreground' : 'background'; + + if (styleCodes[1] === 5) { + set8BitColor(styleCodes, colorType); + } else if (styleCodes[1] === 2) { + set24BitColor(styleCodes, colorType); + } + } else { + setBasicFormatters(styleCodes); + } + + } else { + // Unsupported sequence so simply hide it. + } + + } else { + currentPos = startPos; + } + } + + if (sequenceFound === false) { + buffer += text.charAt(currentPos); + currentPos++; + } + } + + // Flush remaining text buffer if not empty. + if (buffer) { + appendStylizedStringToContainer(root, buffer, styleNames, customFgColor, customBgColor); + } + + return root; + + /** + * Change the foreground or background color by clearing the current color + * and adding the new one. + * @param colorType If `'foreground'`, will change the foreground color, if + * `'background'`, will change the background color. + * @param color Color to change to. If `undefined` or not provided, + * will clear current color without adding a new one. + */ + function changeColor(colorType: 'foreground' | 'background', color?: RGBA | undefined): void { + if (colorType === 'foreground') { + customFgColor = color; + } else if (colorType === 'background') { + customBgColor = color; + } + styleNames = styleNames.filter(style => style !== `code-${colorType}-colored`); + if (color !== undefined) { + styleNames.push(`code-${colorType}-colored`); + } + } + + /** + * Calculate and set basic ANSI formatting. Supports bold, italic, underline, + * normal foreground and background colors, and bright foreground and + * background colors. Not to be used for codes containing advanced colors. + * Will ignore invalid codes. + * @param styleCodes Array of ANSI basic styling numbers, which will be + * applied in order. New colors and backgrounds clear old ones; new formatting + * does not. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code } + */ + function setBasicFormatters(styleCodes: number[]): void { + for (let code of styleCodes) { + switch (code) { + case 0: { + styleNames = []; + customFgColor = undefined; + customBgColor = undefined; + break; + } + case 1: { + styleNames.push('code-bold'); + break; + } + case 3: { + styleNames.push('code-italic'); + break; + } + case 4: { + styleNames.push('code-underline'); + break; + } + case 39: { + changeColor('foreground', undefined); + break; + } + case 49: { + changeColor('background', undefined); + break; + } + default: { + setBasicColor(code); + break; + } + } + } + } + + /** + * Calculate and set styling for complicated 24-bit ANSI color codes. + * @param styleCodes Full list of integer codes that make up the full ANSI + * sequence, including the two defining codes and the three RGB codes. + * @param colorType If `'foreground'`, will set foreground color, if + * `'background'`, will set background color. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code#24-bit } + */ + function set24BitColor(styleCodes: number[], colorType: 'foreground' | 'background'): void { + if (styleCodes.length >= 5 && + styleCodes[2] >= 0 && styleCodes[2] <= 255 && + styleCodes[3] >= 0 && styleCodes[3] <= 255 && + styleCodes[4] >= 0 && styleCodes[4] <= 255) { + const customColor = new RGBA(styleCodes[2], styleCodes[3], styleCodes[4]); + changeColor(colorType, customColor); + } + } + + /** + * Calculate and set styling for advanced 8-bit ANSI color codes. + * @param styleCodes Full list of integer codes that make up the ANSI + * sequence, including the two defining codes and the one color code. + * @param colorType If `'foreground'`, will set foreground color, if + * `'background'`, will set background color. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit } + */ + function set8BitColor(styleCodes: number[], colorType: 'foreground' | 'background'): void { + let colorNumber = styleCodes[2]; + const color = calcANSI8bitColor(colorNumber); + + if (color) { + changeColor(colorType, color); + } else if (colorNumber >= 0 && colorNumber <= 15) { + // Need to map to one of the four basic color ranges (30-37, 90-97, 40-47, 100-107) + colorNumber += 30; + if (colorNumber >= 38) { + // Bright colors + colorNumber += 52; + } + if (colorType === 'background') { + colorNumber += 10; + } + setBasicColor(colorNumber); + } + } + + /** + * Calculate and set styling for basic bright and dark ANSI color codes. Uses + * theme colors if available. Automatically distinguishes between foreground + * and background colors; does not support color-clearing codes 39 and 49. + * @param styleCode Integer color code on one of the following ranges: + * [30-37, 90-97, 40-47, 100-107]. If not on one of these ranges, will do + * nothing. + */ + function setBasicColor(styleCode: number): void { + const theme = themeService.getColorTheme(); + let colorType: 'foreground' | 'background' | undefined; + let colorIndex: number | undefined; + + if (styleCode >= 30 && styleCode <= 37) { + colorIndex = styleCode - 30; + colorType = 'foreground'; + } else if (styleCode >= 90 && styleCode <= 97) { + colorIndex = (styleCode - 90) + 8; // High-intensity (bright) + colorType = 'foreground'; + } else if (styleCode >= 40 && styleCode <= 47) { + colorIndex = styleCode - 40; + colorType = 'background'; + } else if (styleCode >= 100 && styleCode <= 107) { + colorIndex = (styleCode - 100) + 8; // High-intensity (bright) + colorType = 'background'; + } + + if (colorIndex !== undefined && colorType) { + const colorName = ansiColorIdentifiers[colorIndex]; + const color = theme.getColor(colorName); + if (color) { + changeColor(colorType, color.rgba); + } + } + } +} + +/** + * @param root The {@link HTMLElement} to append the content to. + * @param stringContent The text content to be appended. + * @param cssClasses The list of CSS styles to apply to the text content. + * @param linkDetector The {@link LinkDetector} responsible for generating links from {@param stringContent}. + * @param customTextColor If provided, will apply custom color with inline style. + * @param customBackgroundColor If provided, will apply custom color with inline style. + */ +export function appendStylizedStringToContainer( + root: HTMLElement, + stringContent: string, + cssClasses: string[], + customTextColor?: RGBA, + customBackgroundColor?: RGBA +): void { + if (!root || !stringContent) { + return; + } + + const container = linkify(stringContent, true); + container.className = cssClasses.join(' '); + if (customTextColor) { + container.style.color = + Color.Format.CSS.formatRGB(new Color(customTextColor)); + } + if (customBackgroundColor) { + container.style.backgroundColor = + Color.Format.CSS.formatRGB(new Color(customBackgroundColor)); + } + + root.appendChild(container); +} + +function linkify(text: string, splitLines?: boolean): HTMLElement { + if (splitLines) { + const lines = text.split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + lines[i] = lines[i] + '\n'; + } + if (!lines[lines.length - 1]) { + // Remove the last element ('') that split added. + lines.pop(); + } + const elements = lines.map(line => linkify(line)); + if (elements.length === 1) { + // Do not wrap single line with extra span. + return elements[0]; + } + const container = document.createElement('span'); + elements.forEach(e => container.appendChild(e)); + return container; + } + + const container = document.createElement('span'); + container.appendChild(document.createTextNode(text)); + return container; +} + + + +/** + * Calculate the color from the color set defined in the ANSI 8-bit standard. + * Standard and high intensity colors are not defined in the standard as specific + * colors, so these and invalid colors return `undefined`. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit } for info. + * @param colorNumber The number (ranging from 16 to 255) referring to the color + * desired. + */ +export function calcANSI8bitColor(colorNumber: number): RGBA | undefined { + if (colorNumber % 1 !== 0) { + // Should be integer + // {{SQL CARBON EDIT}} @todo anthonydresser 4/12/19 this is necessary because we don't use strict null checks + return undefined; + } if (colorNumber >= 16 && colorNumber <= 231) { + // Converts to one of 216 RGB colors + colorNumber -= 16; + + let blue: number = colorNumber % 6; + colorNumber = (colorNumber - blue) / 6; + let green: number = colorNumber % 6; + colorNumber = (colorNumber - green) / 6; + let red: number = colorNumber; + + // red, green, blue now range on [0, 5], need to map to [0,255] + const convFactor: number = 255 / 5; + blue = Math.round(blue * convFactor); + green = Math.round(green * convFactor); + red = Math.round(red * convFactor); + + return new RGBA(red, green, blue); + } else if (colorNumber >= 232 && colorNumber <= 255) { + // Converts to a grayscale value + colorNumber -= 232; + const colorLevel: number = Math.round(colorNumber / 23 * 255); + return new RGBA(colorLevel, colorLevel, colorLevel); + } else { + // {{SQL CARBON EDIT}} @todo anthonydresser 4/12/19 this is necessary because we don't use strict null checks + return undefined; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/output/transforms/richTransform.ts b/src/vs/workbench/contrib/notebook/browser/view/output/transforms/richTransform.ts new file mode 100644 index 0000000000..258637febe --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/output/transforms/richTransform.ts @@ -0,0 +1,249 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRenderOutput, CellOutputKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { registerOutputTransform } from 'vs/workbench/contrib/notebook/browser/notebookRegistry'; +import * as DOM from 'vs/base/browser/dom'; +import { INotebookEditor, IOutputTransformContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { isArray } from 'vs/base/common/types'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { URI } from 'vs/base/common/uri'; +import { MarkdownRenderer } from 'vs/workbench/contrib/notebook/browser/view/renderers/mdRenderer'; + +class RichRenderer implements IOutputTransformContribution { + private _mdRenderer: MarkdownRenderer; + private _richMimeTypeRenderers = new Map IRenderOutput>(); + + constructor( + public notebookEditor: INotebookEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IModelService private readonly modelService: IModelService, + @IModeService private readonly modeService: IModeService + ) { + this._mdRenderer = instantiationService.createInstance(MarkdownRenderer); + this._richMimeTypeRenderers.set('application/json', this.renderJSON.bind(this)); + this._richMimeTypeRenderers.set('application/javascript', this.renderJavaScript.bind(this)); + this._richMimeTypeRenderers.set('text/html', this.renderHTML.bind(this)); + this._richMimeTypeRenderers.set('image/svg+xml', this.renderSVG.bind(this)); + this._richMimeTypeRenderers.set('text/markdown', this.renderMarkdown.bind(this)); + this._richMimeTypeRenderers.set('image/png', this.renderPNG.bind(this)); + this._richMimeTypeRenderers.set('image/jpeg', this.renderJavaScript.bind(this)); + this._richMimeTypeRenderers.set('text/plain', this.renderPlainText.bind(this)); + this._richMimeTypeRenderers.set('text/x-javascript', this.renderCode.bind(this)); + } + + render(output: any, container: HTMLElement, preferredMimeType: string | undefined): IRenderOutput { + if (!output.data) { + const contentNode = document.createElement('p'); + contentNode.innerText = `No data could be found for output.`; + container.appendChild(contentNode); + + return { + hasDynamicHeight: false + }; + } + + if (!preferredMimeType || !this._richMimeTypeRenderers.has(preferredMimeType)) { + const contentNode = document.createElement('p'); + let mimeTypes = []; + for (const property in output.data) { + mimeTypes.push(property); + } + + let mimeTypesMessage = mimeTypes.join(', '); + + contentNode.innerText = `No renderer could be found for output. It has the following MIME types: ${mimeTypesMessage}`; + container.appendChild(contentNode); + + return { + hasDynamicHeight: false + }; + } + + let renderer = this._richMimeTypeRenderers.get(preferredMimeType); + return renderer!(output, container); + } + + renderJSON(output: any, container: HTMLElement) { + let data = output.data['application/json']; + let str = JSON.stringify(data, null, '\t'); + + const editor = this.instantiationService.createInstance(CodeEditorWidget, container, { + ...getOutputSimpleEditorOptions(), + dimension: { + width: 0, + height: 0 + } + }, { + isSimpleWidget: true + }); + + let mode = this.modeService.create('json'); + let resource = URI.parse(`notebook-output-${Date.now()}.json`); + const textModel = this.modelService.createModel(str, mode, resource, false); + editor.setModel(textModel); + + let width = this.notebookEditor.getLayoutInfo().width; + let fontInfo = this.notebookEditor.getLayoutInfo().fontInfo; + let height = Math.min(textModel.getLineCount(), 16) * (fontInfo.lineHeight || 18); + + editor.layout({ + height, + width + }); + + container.style.height = `${height + 16}px`; + + return { + hasDynamicHeight: true + }; + } + + renderCode(output: any, container: HTMLElement) { + let data = output.data['text/x-javascript']; + let str = isArray(data) ? data.join('') : data; + + const editor = this.instantiationService.createInstance(CodeEditorWidget, container, { + ...getOutputSimpleEditorOptions(), + dimension: { + width: 0, + height: 0 + } + }, { + isSimpleWidget: true + }); + + let mode = this.modeService.create('javascript'); + let resource = URI.parse(`notebook-output-${Date.now()}.js`); + const textModel = this.modelService.createModel(str, mode, resource, false); + editor.setModel(textModel); + + let width = this.notebookEditor.getLayoutInfo().width; + let fontInfo = this.notebookEditor.getLayoutInfo().fontInfo; + let height = Math.min(textModel.getLineCount(), 16) * (fontInfo.lineHeight || 18); + + editor.layout({ + height, + width + }); + + container.style.height = `${height + 16}px`; + + return { + hasDynamicHeight: true + }; + } + + renderJavaScript(output: any, container: HTMLElement) { + let data = output.data['application/javascript']; + let str = isArray(data) ? data.join('') : data; + let scriptVal = ``; + return { + shadowContent: scriptVal, + hasDynamicHeight: false + }; + } + + renderHTML(output: any, container: HTMLElement) { + let data = output.data['text/html']; + let str = isArray(data) ? data.join('') : data; + return { + shadowContent: str, + hasDynamicHeight: false + }; + + } + + renderSVG(output: any, container: HTMLElement) { + let data = output.data['image/svg+xml']; + let str = isArray(data) ? data.join('') : data; + return { + shadowContent: str, + hasDynamicHeight: false + }; + } + + renderMarkdown(output: any, container: HTMLElement) { + let data = output.data['text/markdown']; + const str = isArray(data) ? data.join('') : data; + const mdOutput = document.createElement('div'); + mdOutput.appendChild(this._mdRenderer.render({ value: str, isTrusted: false, supportThemeIcons: true }).element); + container.appendChild(mdOutput); + + return { + hasDynamicHeight: true + }; + } + + renderPNG(output: any, container: HTMLElement) { + const image = document.createElement('img'); + image.src = `data:image/png;base64,${output.data['image/png']}`; + const display = document.createElement('div'); + DOM.addClasses(display, 'display'); + display.appendChild(image); + container.appendChild(display); + return { + hasDynamicHeight: true + }; + + } + + renderJPEG(output: any, container: HTMLElement) { + const image = document.createElement('img'); + image.src = `data:image/jpeg;base64,${output.data['image/jpeg']}`; + const display = document.createElement('div'); + DOM.addClasses(display, 'display'); + display.appendChild(image); + container.appendChild(display); + return { + hasDynamicHeight: true + }; + } + + renderPlainText(output: any, container: HTMLElement) { + let data = output.data['text/plain']; + let str = isArray(data) ? data.join('') : data; + const contentNode = document.createElement('p'); + contentNode.innerText = str; + container.appendChild(contentNode); + + return { + hasDynamicHeight: false + }; + } + + dispose(): void { + } +} + +registerOutputTransform('notebook.output.rich', CellOutputKind.Rich, RichRenderer); + + +export function getOutputSimpleEditorOptions(): IEditorOptions { + return { + readOnly: true, + wordWrap: 'on', + overviewRulerLanes: 0, + glyphMargin: false, + selectOnLineNumbers: false, + hideCursorInOverviewRuler: true, + selectionHighlight: false, + lineDecorationsWidth: 0, + overviewRulerBorder: false, + scrollBeyondLastLine: false, + renderLineHighlight: 'none', + minimap: { + enabled: false + }, + lineNumbers: 'off', + scrollbar: { + alwaysConsumeMouseWheel: false + } + }; +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/output/transforms/streamTransform.ts b/src/vs/workbench/contrib/notebook/browser/view/output/transforms/streamTransform.ts new file mode 100644 index 0000000000..ceee938e6e --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/output/transforms/streamTransform.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRenderOutput, CellOutputKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { registerOutputTransform } from 'vs/workbench/contrib/notebook/browser/notebookRegistry'; +import { INotebookEditor, IOutputTransformContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; + +class StreamRenderer implements IOutputTransformContribution { + constructor( + editor: INotebookEditor + ) { + } + + render(output: any, container: HTMLElement): IRenderOutput { + const contentNode = document.createElement('p'); + contentNode.innerText = output.text; + container.appendChild(contentNode); + return { + hasDynamicHeight: false + }; + + } + + dispose(): void { + } +} + +registerOutputTransform('notebook.output.stream', CellOutputKind.Text, StreamRenderer); diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts new file mode 100644 index 0000000000..b2f8bf84c8 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -0,0 +1,415 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from 'vs/base/browser/dom'; +import { Disposable } from 'vs/base/common/lifecycle'; +import * as path from 'vs/base/common/path'; +import { URI } from 'vs/base/common/uri'; +import * as UUID from 'vs/base/common/uuid'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { INotebookService } from 'vs/workbench/contrib/notebook/browser/notebookService'; +import { IOutput } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IWebviewService, WebviewElement } from 'vs/workbench/contrib/webview/browser/webview'; +import { WebviewResourceScheme } from 'vs/workbench/contrib/webview/common/resourceLoader'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { CELL_MARGIN } from 'vs/workbench/contrib/notebook/browser/constants'; + +export interface IDimentionMessage { + type: 'dimension'; + id: string; + data: DOM.Dimension; +} + + +export interface IScrollAckMessage { + type: 'scroll-ack'; + data: { top: number }; + version: number; +} + +export interface IClearMessage { + type: 'clear'; +} + +export interface ICreationRequestMessage { + type: 'html'; + content: string; + id: string; + outputId: string; + top: number; +} + +export interface IContentWidgetTopRequest { + id: string; + top: number; +} + +export interface IViewScrollTopRequestMessage { + type: 'view-scroll'; + top?: number; + widgets: IContentWidgetTopRequest[]; + version: number; +} + +export interface IScrollRequestMessage { + type: 'scroll'; + id: string; + top: number; + widgetTop?: number; + version: number; +} + +export interface IUpdatePreloadResourceMessage { + type: 'preload'; + resources: string[]; +} + +type IMessage = IDimentionMessage | IScrollAckMessage; + +let version = 0; +export class BackLayerWebView extends Disposable { + element: HTMLElement; + webview: WebviewElement; + insetMapping: Map = new Map(); + reversedInsetMapping: Map = new Map(); + preloadsCache: Map = new Map(); + localResourceRootsCache: URI[] | undefined = undefined; + rendererRootsCache: URI[] = []; + + constructor(public webviewService: IWebviewService, public notebookService: INotebookService, public notebookEditor: INotebookEditor, public environmentSerice: IEnvironmentService) { + super(); + this.element = document.createElement('div'); + + this.element.style.width = `calc(100% - ${CELL_MARGIN * 2}px)`; + this.element.style.height = '1400px'; + this.element.style.position = 'absolute'; + this.element.style.margin = `0px 0 0px ${CELL_MARGIN}px`; + + const loader = URI.file(path.join(environmentSerice.appRoot, '/out/vs/loader.js')).with({ scheme: WebviewResourceScheme }); + + let content = /* html */` + + + + + + + + +
+
+ + +`; + + this.webview = this._createInset(webviewService, content); + this.webview.mountTo(this.element); + + this._register(this.webview.onDidWheel(e => { + this.notebookEditor.triggerScroll(e); + })); + + this._register(this.webview.onMessage((data: IMessage) => { + if (data.type === 'dimension') { + let output = this.reversedInsetMapping.get(data.id); + + if (!output) { + return; + } + + let cell = this.insetMapping.get(output)!.cell; + let height = data.data.height; + let outputHeight = height === 0 ? 0 : height + 16; + + if (cell) { + let outputIndex = cell.outputs.indexOf(output); + cell.updateOutputHeight(outputIndex, outputHeight); + this.notebookEditor.layoutNotebookCell(cell, cell.getCellTotalHeight()); + } + } else if (data.type === 'scroll-ack') { + // const date = new Date(); + // const top = data.data.top; + // console.log('ack top ', top, ' version: ', data.version, ' - ', date.getMinutes() + ':' + date.getSeconds() + ':' + date.getMilliseconds()); + } + })); + } + + private _createInset(webviewService: IWebviewService, content: string) { + this.localResourceRootsCache = [...this.notebookService.getNotebookProviderResourceRoots(), URI.file(this.environmentSerice.appRoot)]; + const webview = webviewService.createWebviewElement('' + UUID.generateUuid(), { + enableFindWidget: false, + }, { + allowScripts: true, + localResourceRoots: this.localResourceRootsCache + }); + webview.html = content; + return webview; + } + + shouldUpdateInset(cell: CellViewModel, output: IOutput, cellTop: number) { + let outputCache = this.insetMapping.get(output)!; + let outputIndex = cell.outputs.indexOf(output); + + let outputOffsetInOutputContainer = cell.getOutputOffset(outputIndex); + let outputOffset = cellTop + cell.editorHeight + 16 /* editor padding */ + 8 + outputOffsetInOutputContainer; + + if (outputOffset === outputCache.cacheOffset) { + return false; + } + + return true; + } + + updateViewScrollTop(top: number, items: { cell: CellViewModel, output: IOutput, cellTop: number }[]) { + let widgets: IContentWidgetTopRequest[] = items.map(item => { + let outputCache = this.insetMapping.get(item.output)!; + let id = outputCache.outputId; + let outputIndex = item.cell.outputs.indexOf(item.output); + + let outputOffsetInOutputContainer = item.cell.getOutputOffset(outputIndex); + let outputOffset = item.cellTop + item.cell.editorHeight + 16 /* editor padding */ + 16 + outputOffsetInOutputContainer; + outputCache.cacheOffset = outputOffset; + + return { + id: id, + top: outputOffset + }; + }); + + let message: IViewScrollTopRequestMessage = { + top, + type: 'view-scroll', + version: version++, + widgets: widgets + }; + + this.webview.sendMessage(message); + } + + createInset(cell: CellViewModel, output: IOutput, cellTop: number, offset: number, shadowContent: string, preloads: Set) { + this.updateRendererPreloads(preloads); + let initialTop = cellTop + offset; + let outputId = UUID.generateUuid(); + + let message: ICreationRequestMessage = { + type: 'html', + content: shadowContent, + id: cell.id, + outputId: outputId, + top: initialTop + }; + + this.webview.sendMessage(message); + this.insetMapping.set(output, { outputId: outputId, cell: cell, cacheOffset: initialTop }); + this.reversedInsetMapping.set(outputId, output); + } + + removeInset(output: IOutput) { + let outputCache = this.insetMapping.get(output); + if (!outputCache) { + return; + } + + let id = outputCache.outputId; + + this.webview.sendMessage({ + type: 'clearOutput', + id: id + }); + this.insetMapping.delete(output); + this.reversedInsetMapping.delete(id); + } + + clearInsets() { + this.webview.sendMessage({ + type: 'clear' + }); + + this.insetMapping = new Map(); + this.reversedInsetMapping = new Map(); + } + + updateRendererPreloads(preloads: Set) { + let resources: string[] = []; + let extensionLocations: URI[] = []; + preloads.forEach(preload => { + let rendererInfo = this.notebookService.getRendererInfo(preload); + + if (rendererInfo) { + let preloadResources = rendererInfo.preloads.map(preloadResource => preloadResource.with({ scheme: WebviewResourceScheme })); + extensionLocations.push(rendererInfo.extensionLocation); + preloadResources.forEach(e => { + if (!this.preloadsCache.has(e.toString())) { + resources.push(e.toString()); + this.preloadsCache.set(e.toString(), true); + } + }); + } + }); + + this.rendererRootsCache = extensionLocations; + const mixedResourceRoots = [...(this.localResourceRootsCache || []), ...this.rendererRootsCache]; + + this.webview.contentOptions = { + allowScripts: true, + enableCommandUris: true, + localResourceRoots: mixedResourceRoots + }; + + let message: IUpdatePreloadResourceMessage = { + type: 'preload', + resources: resources + }; + + this.webview.sendMessage(message); + } + + clearPreloadsCache() { + this.preloadsCache.clear(); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts new file mode 100644 index 0000000000..c0d336ccdf --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts @@ -0,0 +1,386 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getZoomLevel } from 'vs/base/browser/browser'; +import * as DOM from 'vs/base/browser/dom'; +import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; +import { IAction } from 'vs/base/common/actions'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { deepClone } from 'vs/base/common/objects'; +import 'vs/css!vs/workbench/contrib/notebook/browser/notebook'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { InsertCodeCellAboveAction, INotebookCellActionContext, InsertCodeCellBelowAction, InsertMarkdownCellAboveAction, InsertMarkdownCellBelowAction, EditCellAction, SaveCellAction, DeleteCellAction, MoveCellUpAction, MoveCellDownAction } from 'vs/workbench/contrib/notebook/browser/contrib/notebookActions'; +import { CellRenderTemplate, INotebookEditor, ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { CodeCell } from 'vs/workbench/contrib/notebook/browser/view/renderers/codeCell'; +import { StatefullMarkdownCell } from 'vs/workbench/contrib/notebook/browser/view/renderers/markdownCell'; +import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellViewModel } from '../../viewModel/notebookCellViewModel'; +import { ContextAwareMenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { MenuItemAction } from 'vs/platform/actions/common/actions'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { EDITOR_TOOLBAR_HEIGHT, EDITOR_TOP_PADDING, EDITOR_BOTTOM_PADDING } from 'vs/workbench/contrib/notebook/browser/constants'; + +export class NotebookCellListDelegate implements IListVirtualDelegate { + private _lineHeight: number; + private _toolbarHeight = EDITOR_TOOLBAR_HEIGHT; + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + const editorOptions = this.configurationService.getValue('editor'); + this._lineHeight = BareFontInfo.createFromRawSettings(editorOptions, getZoomLevel()).lineHeight; + } + + getHeight(element: CellViewModel): number { + return element.getHeight(this._lineHeight) + this._toolbarHeight; + } + + hasDynamicHeight(element: CellViewModel): boolean { + return element.hasDynamicHeight(); + } + + getTemplateId(element: CellViewModel): string { + if (element.cellKind === CellKind.Markdown) { + return MarkdownCellRenderer.TEMPLATE_ID; + } else { + return CodeCellRenderer.TEMPLATE_ID; + } + } +} + +abstract class AbstractCellRenderer { + protected editorOptions: IEditorOptions; + + constructor( + protected readonly instantiationService: IInstantiationService, + protected readonly notebookEditor: INotebookEditor, + protected readonly contextMenuService: IContextMenuService, + private readonly configurationService: IConfigurationService, + private readonly keybindingService: IKeybindingService, + private readonly notificationService: INotificationService, + language: string, + ) { + const editorOptions = deepClone(this.configurationService.getValue('editor', { overrideIdentifier: language })); + this.editorOptions = { + ...editorOptions, + padding: { + top: EDITOR_TOP_PADDING, + bottom: EDITOR_BOTTOM_PADDING + }, + scrollBeyondLastLine: false, + scrollbar: { + verticalScrollbarSize: 14, + horizontal: 'auto', + useShadows: true, + verticalHasArrows: false, + horizontalHasArrows: false, + alwaysConsumeMouseWheel: false + }, + overviewRulerLanes: 3, + fixedOverflowWidgets: false, + lineNumbersMinChars: 1, + minimap: { enabled: false }, + }; + } + + protected createToolbar(container: HTMLElement): ToolBar { + const toolbar = new ToolBar(container, this.contextMenuService, { + actionViewItemProvider: action => { + if (action instanceof MenuItemAction) { + const item = new ContextAwareMenuEntryActionViewItem(action, this.keybindingService, this.notificationService, this.contextMenuService); + return item; + } + + return undefined; + } + }); + + return toolbar; + } + + showContextMenu(listIndex: number | undefined, element: CellViewModel, x: number, y: number) { + const actions: IAction[] = [ + this.instantiationService.createInstance(InsertCodeCellAboveAction), + this.instantiationService.createInstance(InsertCodeCellBelowAction), + this.instantiationService.createInstance(InsertMarkdownCellAboveAction), + this.instantiationService.createInstance(InsertMarkdownCellBelowAction), + ]; + actions.push(...this.getAdditionalContextMenuActions()); + actions.push(...[ + this.instantiationService.createInstance(DeleteCellAction) + ]); + + this.contextMenuService.showContextMenu({ + getAnchor: () => { + return { + x, + y + }; + }, + getActions: () => actions, + getActionsContext: () => { + cell: element, + notebookEditor: this.notebookEditor + }, + autoSelectFirstItem: false + }); + } + + abstract getAdditionalContextMenuActions(): IAction[]; +} + +export class MarkdownCellRenderer extends AbstractCellRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'markdown_cell'; + private disposables: Map = new Map(); + + constructor( + notehookEditor: INotebookEditor, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + ) { + super(instantiationService, notehookEditor, contextMenuService, configurationService, keybindingService, notificationService, 'markdown'); + } + + get templateId() { + return MarkdownCellRenderer.TEMPLATE_ID; + } + + renderTemplate(container: HTMLElement): CellRenderTemplate { + const codeInnerContent = document.createElement('div'); + DOM.addClasses(codeInnerContent, 'cell', 'code'); + codeInnerContent.style.display = 'none'; + + const disposables = new DisposableStore(); + const toolbar = this.createToolbar(container); + toolbar.setActions([ + this.instantiationService.createInstance(MoveCellUpAction), + this.instantiationService.createInstance(MoveCellDownAction), + this.instantiationService.createInstance(InsertCodeCellBelowAction), + this.instantiationService.createInstance(EditCellAction), + this.instantiationService.createInstance(SaveCellAction), + this.instantiationService.createInstance(DeleteCellAction) + ])(); + disposables.add(toolbar); + + container.appendChild(codeInnerContent); + + const innerContent = document.createElement('div'); + DOM.addClasses(innerContent, 'cell', 'markdown'); + container.appendChild(innerContent); + + const action = document.createElement('div'); + DOM.addClasses(action, 'menu', 'codicon-settings-gear', 'codicon'); + container.appendChild(action); + + DOM.append(container, DOM.$('.notebook-cell-focus-indicator')); + + return { + container: container, + cellContainer: innerContent, + menuContainer: action, + editingContainer: codeInnerContent, + disposables, + toolbar + }; + } + + renderElement(element: CellViewModel, index: number, templateData: CellRenderTemplate, height: number | undefined): void { + templateData.editingContainer!.style.display = 'none'; + templateData.cellContainer.innerHTML = ''; + let renderedHTML = element.getHTML(); + if (renderedHTML) { + templateData.cellContainer.appendChild(renderedHTML); + } + + if (height) { + this.disposables.get(element)?.clear(); + if (!this.disposables.has(element)) { + this.disposables.set(element, new DisposableStore()); + } + let elementDisposable = this.disposables.get(element); + + elementDisposable!.add(DOM.addStandardDisposableListener(templateData.menuContainer!, 'mousedown', e => { + const { top, height } = DOM.getDomNodePagePosition(templateData.menuContainer!); + e.preventDefault(); + + const listIndexAttr = templateData.menuContainer?.parentElement?.getAttribute('data-index'); + const listIndex = listIndexAttr ? Number(listIndexAttr) : undefined; + this.showContextMenu(listIndex, element, e.posx, top + height); + })); + + elementDisposable!.add(DOM.addStandardDisposableListener(templateData.menuContainer!, DOM.EventType.MOUSE_LEAVE, e => { + templateData.menuContainer?.classList.remove('mouseover'); + })); + + elementDisposable!.add(DOM.addStandardDisposableListener(templateData.menuContainer!, DOM.EventType.MOUSE_ENTER, e => { + templateData.menuContainer?.classList.add('mouseover'); + })); + + elementDisposable!.add(new StatefullMarkdownCell(this.notebookEditor, element, templateData, this.editorOptions, this.instantiationService)); + } + + templateData.toolbar!.context = { + cell: element, + notebookEditor: this.notebookEditor + }; + } + + getAdditionalContextMenuActions(): IAction[] { + return [ + this.instantiationService.createInstance(EditCellAction), + this.instantiationService.createInstance(SaveCellAction), + ]; + } + + disposeTemplate(templateData: CellRenderTemplate): void { + // throw nerendererw Error('Method not implemented.'); + + } + + disposeElement(element: ICellViewModel, index: number, templateData: CellRenderTemplate, height: number | undefined): void { + if (height) { + this.disposables.get(element)?.clear(); + } + } +} + +export class CodeCellRenderer extends AbstractCellRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'code_cell'; + private disposables: Map = new Map(); + + constructor( + protected notebookEditor: INotebookEditor, + private renderedEditors: Map, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + ) { + super(instantiationService, notebookEditor, contextMenuService, configurationService, keybindingService, notificationService, 'python'); + } + + get templateId() { + return CodeCellRenderer.TEMPLATE_ID; + } + + renderTemplate(container: HTMLElement): CellRenderTemplate { + const disposables = new DisposableStore(); + const toolbarContainer = document.createElement('div'); + container.appendChild(toolbarContainer); + DOM.addClasses(toolbarContainer, 'menu', 'codicon-settings-gear', 'codicon'); + const toolbar = this.createToolbar(container); + toolbar.setActions([ + this.instantiationService.createInstance(MoveCellUpAction), + this.instantiationService.createInstance(MoveCellDownAction), + this.instantiationService.createInstance(InsertCodeCellBelowAction), + this.instantiationService.createInstance(DeleteCellAction) + ])(); + disposables.add(toolbar); + + const cellContainer = document.createElement('div'); + DOM.addClasses(cellContainer, 'cell', 'code'); + container.appendChild(cellContainer); + const editor = this.instantiationService.createInstance(CodeEditorWidget, cellContainer, { + ...this.editorOptions, + dimension: { + width: 0, + height: 0 + } + }, {}); + const menuContainer = document.createElement('div'); + DOM.addClasses(menuContainer, 'menu', 'codicon-settings-gear', 'codicon'); + container.appendChild(menuContainer); + + const focusIndicator = DOM.append(container, DOM.$('.notebook-cell-focus-indicator')); + + const outputContainer = document.createElement('div'); + DOM.addClasses(outputContainer, 'output'); + container.appendChild(outputContainer); + + return { + container, + cellContainer, + menuContainer, + focusIndicator, + toolbar, + outputContainer, + editor, + disposables + }; + } + + renderElement(element: CellViewModel, index: number, templateData: CellRenderTemplate, height: number | undefined): void { + if (height === undefined) { + return; + } + + if (templateData.outputContainer) { + templateData.outputContainer!.innerHTML = ''; + } + + this.disposables.get(element)?.clear(); + if (!this.disposables.has(element)) { + this.disposables.set(element, new DisposableStore()); + } + + const elementDisposable = this.disposables.get(element); + + elementDisposable?.add(DOM.addStandardDisposableListener(templateData.menuContainer!, 'mousedown', e => { + let { top, height } = DOM.getDomNodePagePosition(templateData.menuContainer!); + e.preventDefault(); + + const listIndexAttr = templateData.menuContainer?.parentElement?.getAttribute('data-index'); + const listIndex = listIndexAttr ? Number(listIndexAttr) : undefined; + + this.showContextMenu(listIndex, element, e.posx, top + height); + })); + + elementDisposable!.add(DOM.addStandardDisposableListener(templateData.menuContainer!, DOM.EventType.MOUSE_LEAVE, e => { + templateData.menuContainer?.classList.remove('mouseover'); + })); + + elementDisposable!.add(DOM.addStandardDisposableListener(templateData.menuContainer!, DOM.EventType.MOUSE_ENTER, e => { + templateData.menuContainer?.classList.add('mouseover'); + })); + + elementDisposable?.add(this.instantiationService.createInstance(CodeCell, this.notebookEditor, element, templateData)); + this.renderedEditors.set(element, templateData.editor); + + elementDisposable?.add(element.onDidChangeTotalHeight(() => { + templateData.focusIndicator!.style.height = `${element.getIndicatorHeight()}px`; + })); + + templateData.toolbar!.context = { + cell: element, + notebookEditor: this.notebookEditor + }; + } + + getAdditionalContextMenuActions(): IAction[] { + return []; + } + + disposeTemplate(templateData: CellRenderTemplate): void { + templateData.disposables.clear(); + } + + disposeElement(element: ICellViewModel, index: number, templateData: CellRenderTemplate, height: number | undefined): void { + this.disposables.get(element)?.clear(); + this.renderedEditors.delete(element); + templateData.focusIndicator!.style.height = 'initial'; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/codeCell.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/codeCell.ts new file mode 100644 index 0000000000..7992f47778 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/codeCell.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import * as DOM from 'vs/base/browser/dom'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { getResizesObserver } from 'vs/workbench/contrib/notebook/browser/view/renderers/sizeObserver'; +import { IOutput, ITransformedDisplayOutputDto, IRenderOutput, CellOutputKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellRenderTemplate, INotebookEditor, CellFocusMode } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { raceCancellation } from 'vs/base/common/async'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { INotebookService } from 'vs/workbench/contrib/notebook/browser/notebookService'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { CELL_MARGIN, EDITOR_TOP_PADDING, EDITOR_BOTTOM_PADDING } from 'vs/workbench/contrib/notebook/browser/constants'; + +interface IMimeTypeRenderer extends IQuickPickItem { + index: number; +} + +export class CodeCell extends Disposable { + private outputResizeListeners = new Map(); + private outputElements = new Map(); + constructor( + private notebookEditor: INotebookEditor, + private viewCell: CellViewModel, + private templateData: CellRenderTemplate, + @INotebookService private notebookService: INotebookService, + @IQuickInputService private readonly quickInputService: IQuickInputService + ) { + super(); + + let width: number; + const listDimension = notebookEditor.getLayoutInfo(); + width = listDimension.width - CELL_MARGIN * 2; + const lineNum = viewCell.lineCount; + const lineHeight = notebookEditor.getLayoutInfo().fontInfo.lineHeight; + const totalHeight = lineNum * lineHeight + EDITOR_TOP_PADDING + EDITOR_BOTTOM_PADDING; + templateData.editor?.layout( + { + width: width, + height: totalHeight + } + ); + viewCell.editorHeight = totalHeight; + + const cts = new CancellationTokenSource(); + this._register({ dispose() { cts.dispose(true); } }); + raceCancellation(viewCell.resolveTextModel(), cts.token).then(model => { + if (model && templateData.editor) { + templateData.editor.setModel(model); + viewCell.attachTextEditor(templateData.editor); + if (notebookEditor.getActiveCell() === viewCell && viewCell.focusMode === CellFocusMode.Editor) { + templateData.editor?.focus(); + } + + let realContentHeight = templateData.editor?.getContentHeight(); + let width: number; + const listDimension = notebookEditor.getLayoutInfo(); + width = listDimension.width - CELL_MARGIN * 2; + + if (realContentHeight !== undefined && realContentHeight !== totalHeight) { + templateData.editor?.layout( + { + width: width, + height: realContentHeight + } + ); + + viewCell.editorHeight = realContentHeight; + } + + if (this.notebookEditor.getActiveCell() === this.viewCell && viewCell.focusMode === CellFocusMode.Editor) { + templateData.editor?.focus(); + } + } + }); + + this._register(viewCell.onDidChangeFocusMode(() => { + if (viewCell.focusMode === CellFocusMode.Editor) { + templateData.editor?.focus(); + } + })); + + let cellWidthResizeObserver = getResizesObserver(templateData.cellContainer, { + width: width, + height: totalHeight + }, () => { + let newWidth = cellWidthResizeObserver.getWidth(); + let realContentHeight = templateData.editor!.getContentHeight(); + templateData.editor?.layout( + { + width: newWidth, + height: realContentHeight + } + ); + + viewCell.editorHeight = realContentHeight; + }); + + cellWidthResizeObserver.startObserving(); + this._register(cellWidthResizeObserver); + + this._register(templateData.editor!.onDidContentSizeChange((e) => { + if (e.contentHeightChanged) { + if (this.viewCell.editorHeight !== e.contentHeight) { + let viewLayout = templateData.editor!.getLayoutInfo(); + + templateData.editor?.layout( + { + width: viewLayout.width, + height: e.contentHeight + } + ); + + this.viewCell.editorHeight = e.contentHeight; + this.relayoutCell(); + } + + } + })); + + this._register(templateData.editor!.onDidChangeCursorSelection(() => { + const primarySelection = templateData.editor!.getSelection(); + + if (primarySelection) { + this.notebookEditor.revealLineInView(viewCell, primarySelection!.positionLineNumber); + } + })); + + this._register(viewCell.onDidChangeOutputs((splices) => { + if (!splices.length) { + return; + } + + if (this.viewCell.outputs.length) { + this.templateData.outputContainer!.style.display = 'block'; + } else { + this.templateData.outputContainer!.style.display = 'none'; + } + + let reversedSplices = splices.reverse(); + + reversedSplices.forEach(splice => { + viewCell.spliceOutputHeights(splice[0], splice[1], splice[2].map(_ => 0)); + }); + + let removedKeys: IOutput[] = []; + + this.outputElements.forEach((value, key) => { + if (viewCell.outputs.indexOf(key) < 0) { + // already removed + removedKeys.push(key); + // remove element from DOM + this.templateData?.outputContainer?.removeChild(value); + this.notebookEditor.removeInset(key); + } + }); + + removedKeys.forEach(key => { + // remove element cache + this.outputElements.delete(key); + // remove elment resize listener if there is one + this.outputResizeListeners.delete(key); + }); + + let prevElement: HTMLElement | undefined = undefined; + + this.viewCell.outputs.reverse().forEach(output => { + if (this.outputElements.has(output)) { + // already exist + prevElement = this.outputElements.get(output); + return; + } + + // newly added element + let currIndex = this.viewCell.outputs.indexOf(output); + this.renderOutput(output, currIndex, prevElement); + prevElement = this.outputElements.get(output); + }); + + let editorHeight = templateData.editor!.getContentHeight(); + viewCell.editorHeight = editorHeight; + this.relayoutCell(); + })); + + if (viewCell.outputs.length > 0) { + this.templateData.outputContainer!.style.display = 'block'; + // there are outputs, we need to calcualte their sizes and trigger relayout + // @todo, if there is no resizable output, we should not check their height individually, which hurts the performance + for (let index = 0; index < this.viewCell.outputs.length; index++) { + const currOutput = this.viewCell.outputs[index]; + + // always add to the end + this.renderOutput(currOutput, index, undefined); + } + + viewCell.editorHeight = totalHeight; + this.relayoutCell(); + } else { + // noop + this.templateData.outputContainer!.style.display = 'none'; + } + } + + renderOutput(currOutput: IOutput, index: number, beforeElement?: HTMLElement) { + if (!this.outputResizeListeners.has(currOutput)) { + this.outputResizeListeners.set(currOutput, new DisposableStore()); + } + + let outputItemDiv = document.createElement('div'); + let result: IRenderOutput | undefined = undefined; + + if (currOutput.outputKind === CellOutputKind.Rich) { + let transformedDisplayOutput = currOutput as ITransformedDisplayOutputDto; + + if (transformedDisplayOutput.orderedMimeTypes.length > 1) { + outputItemDiv.style.position = 'relative'; + const mimeTypePicker = DOM.$('.multi-mimetype-output'); + DOM.addClasses(mimeTypePicker, 'codicon', 'codicon-list-selection'); + outputItemDiv.appendChild(mimeTypePicker); + this.outputResizeListeners.get(currOutput)!.add(DOM.addStandardDisposableListener(mimeTypePicker, 'mousedown', async e => { + e.preventDefault(); + e.stopPropagation(); + await this.pickActiveMimeTypeRenderer(transformedDisplayOutput); + })); + } + let pickedMimeTypeRenderer = currOutput.orderedMimeTypes[currOutput.pickedMimeTypeIndex]; + + if (pickedMimeTypeRenderer.isResolved) { + // html + result = this.notebookEditor.getOutputRenderer().render({ outputKind: CellOutputKind.Rich, data: { 'text/html': pickedMimeTypeRenderer.output! } } as any, outputItemDiv, 'text/html'); + } else { + result = this.notebookEditor.getOutputRenderer().render(currOutput, outputItemDiv, pickedMimeTypeRenderer.mimeType); + } + } else { + // for text and error, there is no mimetype + result = this.notebookEditor.getOutputRenderer().render(currOutput, outputItemDiv, undefined); + } + + if (!result) { + this.viewCell.updateOutputHeight(index, 0); + return; + } + + this.outputElements.set(currOutput, outputItemDiv); + + if (beforeElement) { + this.templateData.outputContainer?.insertBefore(outputItemDiv, beforeElement); + } else { + this.templateData.outputContainer?.appendChild(outputItemDiv); + } + + if (result.shadowContent) { + this.viewCell.selfSizeMonitoring = true; + let editorHeight = this.viewCell.editorHeight; + this.notebookEditor.createInset(this.viewCell, currOutput, result.shadowContent, editorHeight + 8 + this.viewCell.getOutputOffset(index)); + } else { + DOM.addClass(outputItemDiv, 'foreground'); + } + + let hasDynamicHeight = result.hasDynamicHeight; + + if (hasDynamicHeight) { + let clientHeight = outputItemDiv.clientHeight; + let listDimension = this.notebookEditor.getLayoutInfo(); + let dimension = listDimension ? { + width: listDimension.width - CELL_MARGIN * 2, + height: clientHeight + } : undefined; + const elementSizeObserver = getResizesObserver(outputItemDiv, dimension, () => { + if (this.templateData.outputContainer && document.body.contains(this.templateData.outputContainer!)) { + let height = elementSizeObserver.getHeight() + 8 * 2; // include padding + + if (clientHeight === height) { + // console.log(this.viewCell.outputs); + return; + } + + const currIndex = this.viewCell.outputs.indexOf(currOutput); + if (currIndex < 0) { + return; + } + + this.viewCell.updateOutputHeight(currIndex, height); + this.relayoutCell(); + } + }); + elementSizeObserver.startObserving(); + this.outputResizeListeners.get(currOutput)!.add(elementSizeObserver); + this.viewCell.updateOutputHeight(index, clientHeight); + } else { + if (result.shadowContent) { + // webview + // noop + // let cachedHeight = this.viewCell.getOutputHeight(currOutput); + } else { + // static output + + // @TODO, if we stop checking output height, we need to evaluate it later when checking the height of output container + let clientHeight = outputItemDiv.clientHeight; + this.viewCell.updateOutputHeight(index, clientHeight); + } + } + } + + generateRendererInfo(renderId: number | undefined): string { + if (renderId === undefined || renderId === -1) { + return 'builtin'; + } + + let renderInfo = this.notebookService.getRendererInfo(renderId); + + if (renderInfo) { + return renderInfo.id.value; + } + + return 'builtin'; + } + + async pickActiveMimeTypeRenderer(output: ITransformedDisplayOutputDto) { + let currIndex = output.pickedMimeTypeIndex; + const items = output.orderedMimeTypes.map((mimeType, index): IMimeTypeRenderer => ({ + label: mimeType.mimeType, + id: mimeType.mimeType, + index: index, + picked: index === currIndex, + description: this.generateRendererInfo(mimeType.rendererId) + (index === currIndex + ? nls.localize('curruentActiveMimeType', " (Currently Active)") + : ''), + })); + + const picker = this.quickInputService.createQuickPick(); + picker.items = items; + picker.activeItems = items.filter(item => !!item.picked); + picker.placeholder = nls.localize('promptChooseMimeType.placeHolder', "Select output mimetype to render for current output"); + + const pick = await new Promise(resolve => { + picker.onDidAccept(() => { + resolve(picker.selectedItems.length === 1 ? (picker.selectedItems[0] as IMimeTypeRenderer).index : undefined); + picker.dispose(); + }); + picker.show(); + }); + + if (pick === undefined) { + return; + } + + if (pick !== currIndex) { + // user chooses another mimetype + let index = this.viewCell.outputs.indexOf(output); + let nextElement = index + 1 < this.viewCell.outputs.length ? this.outputElements.get(this.viewCell.outputs[index + 1]) : undefined; + this.outputResizeListeners.get(output)?.clear(); + let element = this.outputElements.get(output); + if (element) { + this.templateData?.outputContainer?.removeChild(element); + this.notebookEditor.removeInset(output); + } + + output.pickedMimeTypeIndex = pick; + + this.renderOutput(output, index, nextElement); + this.relayoutCell(); + } + } + + relayoutCell() { + this.notebookEditor.layoutNotebookCell(this.viewCell, this.viewCell.getCellTotalHeight()); + } + + dispose() { + this.viewCell.detachTextEditor(); + this.outputResizeListeners.forEach((value) => { + value.dispose(); + }); + + this.templateData.focusIndicator!.style.height = 'initial'; + + super.dispose(); + } +} + diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/markdownCell.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/markdownCell.ts new file mode 100644 index 0000000000..9af57ee1b6 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/markdownCell.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { getResizesObserver } from 'vs/workbench/contrib/notebook/browser/view/renderers/sizeObserver'; +import { INotebookEditor, CellRenderTemplate, CellFocusMode, CellState } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { raceCancellation } from 'vs/base/common/async'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { CELL_MARGIN, EDITOR_TOP_PADDING, EDITOR_BOTTOM_PADDING } from 'vs/workbench/contrib/notebook/browser/constants'; + +export class StatefullMarkdownCell extends Disposable { + private editor: CodeEditorWidget | null = null; + private cellContainer: HTMLElement; + private editingContainer?: HTMLElement; + + private localDisposables: DisposableStore; + + constructor( + notebookEditor: INotebookEditor, + public viewCell: CellViewModel, + templateData: CellRenderTemplate, + editorOptions: IEditorOptions, + instantiationService: IInstantiationService + ) { + super(); + + this.cellContainer = templateData.cellContainer; + this.editingContainer = templateData.editingContainer; + this.localDisposables = new DisposableStore(); + this._register(this.localDisposables); + + const viewUpdate = () => { + if (viewCell.state === CellState.Editing) { + // switch to editing mode + let width: number; + const listDimension = notebookEditor.getLayoutInfo(); + width = listDimension.width - CELL_MARGIN * 2; + // if (listDimension) { + // } else { + // width = this.cellContainer.clientWidth - 24 /** for scrollbar and margin right */; + // } + + const lineNum = viewCell.lineCount; + const lineHeight = notebookEditor.getLayoutInfo().fontInfo.lineHeight; + const totalHeight = Math.max(lineNum, 1) * lineHeight + EDITOR_TOP_PADDING + EDITOR_BOTTOM_PADDING; + + if (this.editor) { + // not first time, we don't need to create editor or bind listeners + this.editingContainer!.style.display = 'block'; + viewCell.attachTextEditor(this.editor!); + if (notebookEditor.getActiveCell() === viewCell) { + this.editor!.focus(); + } + } else { + this.editingContainer!.style.display = 'block'; + this.editingContainer!.innerHTML = ''; + this.editor = instantiationService.createInstance(CodeEditorWidget, this.editingContainer!, { + ...editorOptions, + dimension: { + width: width, + height: totalHeight + } + }, {}); + + + const cts = new CancellationTokenSource(); + this._register({ dispose() { cts.dispose(true); } }); + raceCancellation(viewCell.resolveTextModel(), cts.token).then(model => { + if (!model) { + return; + } + + this.editor!.setModel(model); + if (notebookEditor.getActiveCell() === viewCell) { + this.editor!.focus(); + } + + const realContentHeight = this.editor!.getContentHeight(); + if (realContentHeight !== totalHeight) { + this.editor!.layout( + { + width: width, + height: realContentHeight + } + ); + } + + viewCell.attachTextEditor(this.editor!); + + this.localDisposables.add(model.onDidChangeContent(() => { + viewCell.setText(model.getLinesContent()); + let clientHeight = this.cellContainer.clientHeight; + this.cellContainer.innerHTML = ''; + let renderedHTML = viewCell.getHTML(); + if (renderedHTML) { + this.cellContainer.appendChild(renderedHTML); + clientHeight = this.cellContainer.clientHeight; + } + + notebookEditor.layoutNotebookCell(viewCell, this.editor!.getContentHeight() + 32 + clientHeight); + })); + + if (viewCell.state === CellState.Editing) { + this.editor!.focus(); + } + }); + + this.localDisposables.add(this.editor.onDidContentSizeChange(e => { + let viewLayout = this.editor!.getLayoutInfo(); + + if (e.contentHeightChanged) { + this.editor!.layout( + { + width: viewLayout.width, + height: e.contentHeight + } + ); + const clientHeight = this.cellContainer.clientHeight; + notebookEditor.layoutNotebookCell(viewCell, e.contentHeight + 32 + clientHeight); + } + })); + + let cellWidthResizeObserver = getResizesObserver(templateData.editingContainer!, { + width: width, + height: totalHeight + }, () => { + let newWidth = cellWidthResizeObserver.getWidth(); + let realContentHeight = this.editor!.getContentHeight(); + let layoutInfo = this.editor!.getLayoutInfo(); + + // the dimension generated by the resize observer are float numbers, let's round it a bit to avoid relayout. + if (newWidth < layoutInfo.width - 0.3 || layoutInfo.width + 0.3 < newWidth) { + this.editor!.layout( + { + width: newWidth, + height: realContentHeight + } + ); + } + }); + + cellWidthResizeObserver.startObserving(); + this.localDisposables.add(cellWidthResizeObserver); + + let markdownRenderer = viewCell.getMarkdownRenderer(); + this.cellContainer.innerHTML = ''; + let renderedHTML = viewCell.getHTML(); + if (renderedHTML) { + this.cellContainer.appendChild(renderedHTML); + this.localDisposables.add(markdownRenderer.onDidUpdateRender(() => { + const clientHeight = this.cellContainer.clientHeight; + notebookEditor.layoutNotebookCell(viewCell, clientHeight); + })); + } + } + + const clientHeight = this.cellContainer.clientHeight; + notebookEditor.layoutNotebookCell(viewCell, totalHeight + 32 + clientHeight); + this.editor.focus(); + } else { + this.viewCell.detachTextEditor(); + if (this.editor) { + // switch from editing mode + this.editingContainer!.style.display = 'none'; + const clientHeight = templateData.container.clientHeight; + notebookEditor.layoutNotebookCell(viewCell, clientHeight); + } else { + // first time, readonly mode + this.editingContainer!.style.display = 'none'; + + this.cellContainer.innerHTML = ''; + let markdownRenderer = viewCell.getMarkdownRenderer(); + let renderedHTML = viewCell.getHTML(); + if (renderedHTML) { + this.cellContainer.appendChild(renderedHTML); + } + + this.localDisposables.add(markdownRenderer.onDidUpdateRender(() => { + const clientHeight = templateData.container.clientHeight; + notebookEditor.layoutNotebookCell(viewCell, clientHeight); + })); + + this.localDisposables.add(viewCell.onDidChangeContent(() => { + this.cellContainer.innerHTML = ''; + let renderedHTML = viewCell.getHTML(); + if (renderedHTML) { + this.cellContainer.appendChild(renderedHTML); + } + })); + } + } + }; + + this._register(viewCell.onDidChangeCellState(() => { + this.localDisposables.clear(); + viewUpdate(); + })); + + this._register(viewCell.onDidChangeFocusMode(() => { + if (viewCell.focusMode === CellFocusMode.Editor) { + this.editor?.focus(); + } + })); + + viewUpdate(); + } + + dispose() { + this.viewCell.detachTextEditor(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/mdRenderer.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/mdRenderer.ts new file mode 100644 index 0000000000..12ec14fa7f --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/mdRenderer.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { renderMarkdown, MarkdownRenderOptions } from 'vs/base/browser/markdownRenderer'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { tokenizeToString } from 'vs/editor/common/modes/textToHtmlTokenizer'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IDisposable, DisposableStore, Disposable } from 'vs/base/common/lifecycle'; +import { TokenizationRegistry } from 'vs/editor/common/modes'; + +export interface IMarkdownRenderResult extends IDisposable { + element: HTMLElement; +} + +export class MarkdownRenderer extends Disposable { + + private _onDidUpdateRender = this._register(new Emitter()); + readonly onDidUpdateRender: Event = this._onDidUpdateRender.event; + + constructor( + @IModeService private readonly _modeService: IModeService, + @IOpenerService private readonly _openerService: IOpenerService + ) { + super(); + } + + private getOptions(disposeables: DisposableStore): MarkdownRenderOptions { + return { + codeBlockRenderer: (languageAlias, value) => { + // In markdown, + // it is possible that we stumble upon language aliases (e.g.js instead of javascript) + // it is possible no alias is given in which case we fall back to the current editor lang + let modeId: string | null = null; + modeId = this._modeService.getModeIdForLanguageName(languageAlias || ''); + + this._modeService.triggerMode(modeId || ''); + return Promise.resolve(true).then(_ => { + const promise = TokenizationRegistry.getPromise(modeId || ''); + if (promise) { + return promise.then(support => tokenizeToString(value, support)); + } + return tokenizeToString(value, undefined); + }).then(code => { + return `${code}`; + }); + }, + codeBlockRenderCallback: () => this._onDidUpdateRender.fire(), + actionHandler: { + callback: (content) => { + this._openerService.open(content, { fromUserGesture: true }).catch(onUnexpectedError); + }, + disposeables + } + }; + } + + render(markdown: IMarkdownString | undefined): IMarkdownRenderResult { + const disposeables = new DisposableStore(); + + let element: HTMLElement; + if (!markdown) { + element = document.createElement('span'); + } else { + element = renderMarkdown(markdown, this.getOptions(disposeables), { gfm: true }); + } + + return { + element, + dispose: () => disposeables.dispose() + }; + } +} + diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/sizeObserver.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/sizeObserver.ts new file mode 100644 index 0000000000..09be056cfb --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/sizeObserver.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from 'vs/base/browser/dom'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IDimension } from 'vs/editor/common/editorCommon'; +import { ElementSizeObserver } from 'vs/editor/browser/config/elementSizeObserver'; + +declare const ResizeObserver: any; + +export interface IResizeObserver { + startObserving: () => void; + stopObserving: () => void; + getWidth(): number; + getHeight(): number; + dispose(): void; +} + +export class BrowserResizeObserver extends Disposable implements IResizeObserver { + private readonly referenceDomElement: HTMLElement | null; + + private readonly observer: any; + private width: number; + private height: number; + + constructor(referenceDomElement: HTMLElement | null, dimension: IDimension | undefined, changeCallback: () => void) { + super(); + + this.referenceDomElement = referenceDomElement; + this.width = -1; + this.height = -1; + + this.observer = new ResizeObserver((entries: any) => { + for (let entry of entries) { + if (entry.target === referenceDomElement && entry.contentRect) { + if (this.width !== entry.contentRect.width || this.height !== entry.contentRect.height) { + this.width = entry.contentRect.width; + this.height = entry.contentRect.height; + DOM.scheduleAtNextAnimationFrame(() => { + changeCallback(); + }); + } + } + } + }); + } + + getWidth(): number { + return this.width; + } + + getHeight(): number { + return this.height; + } + + startObserving(): void { + this.observer.observe(this.referenceDomElement!); + } + + stopObserving(): void { + this.observer.unobserve(this.referenceDomElement!); + } + + dispose(): void { + this.observer.disconnect(); + super.dispose(); + } +} + +export function getResizesObserver(referenceDomElement: HTMLElement | null, dimension: IDimension | undefined, changeCallback: () => void): IResizeObserver { + if (ResizeObserver) { + return new BrowserResizeObserver(referenceDomElement, dimension, changeCallback); + } else { + return new ElementSizeObserver(referenceDomElement, dimension, changeCallback); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/cellEdit.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/cellEdit.ts new file mode 100644 index 0000000000..7f7313f249 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/cellEdit.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ICell } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IResourceUndoRedoElement, UndoRedoElementType } from 'vs/platform/undoRedo/common/undoRedo'; +import { URI } from 'vs/base/common/uri'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; + + +/** + * It should not modify Undo/Redo stack + */ +export interface ICellEditingDelegate { + insertCell?(index: number, viewCell: CellViewModel): void; + deleteCell?(index: number, cell: ICell): void; + moveCell?(fromIndex: number, toIndex: number): void; +} + +export class InsertCellEdit implements IResourceUndoRedoElement { + type: UndoRedoElementType.Resource = UndoRedoElementType.Resource; + label: string = 'Insert Cell'; + constructor( + public resource: URI, + private insertIndex: number, + private cell: CellViewModel, + private editingDelegate: ICellEditingDelegate + ) { + } + + undo(): void | Promise { + if (!this.editingDelegate.deleteCell) { + throw new Error('Notebook Delete Cell not implemented for Undo/Redo'); + } + + this.editingDelegate.deleteCell(this.insertIndex, this.cell.cell); + } + redo(): void | Promise { + if (!this.editingDelegate.insertCell) { + throw new Error('Notebook Insert Cell not implemented for Undo/Redo'); + } + + this.editingDelegate.insertCell(this.insertIndex, this.cell); + } +} + +export class DeleteCellEdit implements IResourceUndoRedoElement { + type: UndoRedoElementType.Resource = UndoRedoElementType.Resource; + label: string = 'Delete Cell'; + + private _rawCell: ICell; + constructor( + public resource: URI, + private insertIndex: number, + cell: CellViewModel, + private editingDelegate: ICellEditingDelegate, + private instantiationService: IInstantiationService, + private notebookViewModel: NotebookViewModel + ) { + this._rawCell = cell.cell; + + // save inmem text to `ICell` + this._rawCell.source = [cell.getText()]; + } + + undo(): void | Promise { + if (!this.editingDelegate.insertCell) { + throw new Error('Notebook Insert Cell not implemented for Undo/Redo'); + } + + const cell = this.instantiationService.createInstance(CellViewModel, this.notebookViewModel.viewType, this.notebookViewModel.handle, this._rawCell); + this.editingDelegate.insertCell(this.insertIndex, cell); + } + + redo(): void | Promise { + if (!this.editingDelegate.deleteCell) { + throw new Error('Notebook Delete Cell not implemented for Undo/Redo'); + } + + this.editingDelegate.deleteCell(this.insertIndex, this._rawCell); + } +} + +export class MoveCellEdit implements IResourceUndoRedoElement { + type: UndoRedoElementType.Resource = UndoRedoElementType.Resource; + label: string = 'Delete Cell'; + + constructor( + public resource: URI, + private fromIndex: number, + private toIndex: number, + private editingDelegate: ICellEditingDelegate + ) { + } + + undo(): void | Promise { + if (!this.editingDelegate.moveCell) { + throw new Error('Notebook Move Cell not implemented for Undo/Redo'); + } + + this.editingDelegate.moveCell(this.toIndex, this.fromIndex); + } + + redo(): void | Promise { + if (!this.editingDelegate.moveCell) { + throw new Error('Notebook Move Cell not implemented for Undo/Redo'); + } + + this.editingDelegate.moveCell(this.fromIndex, this.toIndex); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel.ts new file mode 100644 index 0000000000..6ae91908ae --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel.ts @@ -0,0 +1,522 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import * as UUID from 'vs/base/common/uuid'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { Range } from 'vs/editor/common/core/range'; +import * as editorCommon from 'vs/editor/common/editorCommon'; +import * as model from 'vs/editor/common/model'; +import { SearchParams } from 'vs/editor/common/model/textModelSearch'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; +import { PrefixSumComputer } from 'vs/editor/common/viewModel/prefixSumComputer'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { MarkdownRenderer } from 'vs/workbench/contrib/notebook/browser/view/renderers/mdRenderer'; +import { CellKind, ICell, IOutput, NotebookCellOutputsSplice } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellFindMatch, CellState, CursorAtBoundary, CellFocusMode, ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { EDITOR_TOP_PADDING, EDITOR_BOTTOM_PADDING, EDITOR_TOOLBAR_HEIGHT } from 'vs/workbench/contrib/notebook/browser/constants'; + +export class CellViewModel extends Disposable implements ICellViewModel { + + private _mdRenderer: MarkdownRenderer | null = null; + private _html: HTMLElement | null = null; + protected readonly _onDidDispose = new Emitter(); + readonly onDidDispose = this._onDidDispose.event; + protected readonly _onDidChangeCellState = new Emitter(); + readonly onDidChangeCellState = this._onDidChangeCellState.event; + protected readonly _onDidChangeFocusMode = new Emitter(); + readonly onDidChangeFocusMode = this._onDidChangeFocusMode.event; + protected readonly _onDidChangeOutputs = new Emitter(); + readonly onDidChangeOutputs = this._onDidChangeOutputs.event; + + protected readonly _onDidChangeTotalHeight = new Emitter(); + readonly onDidChangeTotalHeight = this._onDidChangeTotalHeight.event; + private _outputCollection: number[] = []; + protected _outputsTop: PrefixSumComputer | null = null; + + get handle() { + return this.cell.handle; + } + + get uri() { + return this.cell.uri; + } + + get cellKind() { + return this.cell.cellKind; + } + get lineCount() { + return this.cell.source.length; + } + get outputs() { + return this.cell.outputs; + } + + private _state: CellState = CellState.Preview; + + get state(): CellState { + return this._state; + } + + set state(newState: CellState) { + if (newState === this._state) { + return; + } + + this._state = newState; + this._onDidChangeCellState.fire(); + } + + private _focusMode: CellFocusMode = CellFocusMode.Container; + + get focusMode() { + return this._focusMode; + } + + set focusMode(newMode: CellFocusMode) { + this._focusMode = newMode; + this._onDidChangeFocusMode.fire(); + } + + private _selfSizeMonitoring: boolean = false; + + set selfSizeMonitoring(newVal: boolean) { + this._selfSizeMonitoring = newVal; + } + + get selfSizeMonitoring() { + return this._selfSizeMonitoring; + } + + private _editorHeight = 0; + set editorHeight(height: number) { + this._editorHeight = height; + this._onDidChangeTotalHeight.fire(); + } + + get editorHeight(): number { + return this._editorHeight; + } + + protected readonly _onDidChangeEditorAttachState = new Emitter(); + readonly onDidChangeEditorAttachState = this._onDidChangeEditorAttachState.event; + + get editorAttached(): boolean { + return !!this._textEditor; + } + + private _textModel?: model.ITextModel; + private _textEditor?: ICodeEditor; + private _buffer: model.ITextBuffer | null; + private _editorViewStates: editorCommon.ICodeEditorViewState | null; + private _lastDecorationId: number = 0; + private _resolvedDecorations = new Map(); + private readonly _onDidChangeContent: Emitter = this._register(new Emitter()); + public readonly onDidChangeContent: Event = this._onDidChangeContent.event; + private readonly _onDidChangeCursorSelection: Emitter = this._register(new Emitter()); + public readonly onDidChangeCursorSelection: Event = this._onDidChangeCursorSelection.event; + + private _cursorChangeListener: IDisposable | null = null; + + readonly id: string = UUID.generateUuid(); + + constructor( + readonly viewType: string, + readonly notebookHandle: number, + readonly cell: ICell, + @IInstantiationService private readonly _instaService: IInstantiationService, + @ITextModelService private readonly _modelService: ITextModelService, + ) { + super(); + if (this.cell.onDidChangeOutputs) { + this._register(this.cell.onDidChangeOutputs((splices) => { + this._outputCollection = new Array(this.cell.outputs.length); + this._outputsTop = null; + this._onDidChangeOutputs.fire(splices); + })); + } + + this._outputCollection = new Array(this.cell.outputs.length); + this._buffer = null; + this._editorViewStates = null; + } + + restoreEditorViewState(editorViewStates: editorCommon.ICodeEditorViewState | null) { + this._editorViewStates = editorViewStates; + } + + saveEditorViewState() { + if (this._textEditor) { + this._editorViewStates = this.saveViewState(); + } + + return this._editorViewStates; + } + + + //#region Search + private readonly _hasFindResult = this._register(new Emitter()); + public readonly hasFindResult: Event = this._hasFindResult.event; + + startFind(value: string): CellFindMatch | null { + let cellMatches: model.FindMatch[] = []; + + if (this.assertTextModelAttached()) { + cellMatches = this._textModel!.findMatches(value, false, false, false, null, false); + } else { + if (!this._buffer) { + this._buffer = this.cell.resolveTextBufferFactory().create(model.DefaultEndOfLine.LF); + } + + const lineCount = this._buffer.getLineCount(); + const fullRange = new Range(1, 1, lineCount, this._buffer.getLineLength(lineCount) + 1); + const searchParams = new SearchParams(value, false, false, null); + const searchData = searchParams.parseSearchRequest(); + + if (!searchData) { + return null; + } + + cellMatches = this._buffer.findMatchesLineByLine(fullRange, searchData, false, 1000); + } + + return { + cell: this, + matches: cellMatches + }; + } + + assertTextModelAttached(): boolean { + if (this._textModel && this._textEditor && this._textEditor.getModel() === this._textModel) { + return true; + } + + return false; + } + + private saveViewState(): editorCommon.ICodeEditorViewState | null { + if (!this._textEditor) { + return null; + } + + return this._textEditor.saveViewState(); + } + + + private restoreViewState(state: editorCommon.ICodeEditorViewState | null): void { + if (state) { + this._textEditor?.restoreViewState(state); + } + } + + //#endregion + + hasDynamicHeight() { + if (this.selfSizeMonitoring) { + // if there is an output rendered in the webview, it should always be false + return false; + } + + if (this.cellKind === CellKind.Code) { + if (this.outputs && this.outputs.length > 0) { + // if it contains output, it will be marked as dynamic height + // thus when it's being rendered, the list view will `probeHeight` + // inside which, we will check domNode's height directly instead of doing another `renderElement` with height undefined. + return true; + } + else { + return false; + } + } + + return true; + } + + getHeight(lineHeight: number) { + if (this.cellKind === CellKind.Markdown) { + return 100; + } + else { + return this.lineCount * lineHeight + 16 + EDITOR_TOP_PADDING + EDITOR_BOTTOM_PADDING; + } + } + setText(strs: string[]) { + this.cell.source = strs; + this._html = null; + } + + save() { + if (this._textModel && !this._textModel.isDisposed() && this.state === CellState.Editing) { + let cnt = this._textModel.getLineCount(); + this.cell.source = this._textModel.getLinesContent().map((str, index) => str + (index !== cnt - 1 ? '\n' : '')); + } + } + getText(): string { + if (this._textModel) { + return this._textModel.getValue(); + } + + return this.cell.source.join('\n'); + } + + getHTML(): HTMLElement | null { + if (this.cellKind === CellKind.Markdown) { + if (this._html) { + return this._html; + } + let renderer = this.getMarkdownRenderer(); + this._html = renderer.render({ value: this.getText(), isTrusted: true }).element; + return this._html; + } + return null; + } + + async resolveTextModel(): Promise { + if (!this._textModel) { + const ref = await this._modelService.createModelReference(this.cell.uri); + this._textModel = ref.object.textEditorModel; + this._buffer = this._textModel.getTextBuffer(); + this._register(ref); + this._register(this._textModel.onDidChangeContent(() => { + this.cell.contentChange(); + this._html = null; + this._onDidChangeContent.fire(); + })); + } + return this._textModel; + } + + attachTextEditor(editor: ICodeEditor) { + if (!editor.hasModel()) { + throw new Error('Invalid editor: model is missing'); + } + + if (this._textEditor === editor) { + if (this._cursorChangeListener === null) { + this._cursorChangeListener = this._textEditor.onDidChangeCursorSelection(() => this._onDidChangeCursorSelection.fire()); + this._onDidChangeCursorSelection.fire(); + } + return; + } + + this._textEditor = editor; + + if (this._editorViewStates) { + this.restoreViewState(this._editorViewStates); + } + + this._resolvedDecorations.forEach((value, key) => { + if (key.startsWith('_lazy_')) { + // lazy ones + + const ret = this._textEditor!.deltaDecorations([], [value.options]); + this._resolvedDecorations.get(key)!.id = ret[0]; + } else { + const ret = this._textEditor!.deltaDecorations([], [value.options]); + this._resolvedDecorations.get(key)!.id = ret[0]; + } + }); + + this._cursorChangeListener = this._textEditor.onDidChangeCursorSelection(() => this._onDidChangeCursorSelection.fire()); + this._onDidChangeCursorSelection.fire(); + this._onDidChangeEditorAttachState.fire(true); + } + + detachTextEditor() { + this._editorViewStates = this.saveViewState(); + + // decorations need to be cleared first as editors can be resued. + this._resolvedDecorations.forEach(value => { + let resolvedid = value.id; + + if (resolvedid) { + this._textEditor?.deltaDecorations([resolvedid], []); + } + }); + this._textEditor = undefined; + this._cursorChangeListener?.dispose(); + this._cursorChangeListener = null; + this._onDidChangeEditorAttachState.fire(false); + } + + revealRangeInCenter(range: Range) { + this._textEditor?.revealRangeInCenter(range, editorCommon.ScrollType.Immediate); + } + + setSelection(range: Range) { + this._textEditor?.setSelection(range); + } + + getLineScrollTopOffset(line: number): number { + if (!this._textEditor) { + return 0; + } + + return this._textEditor.getTopForLineNumber(line) + EDITOR_TOP_PADDING + EDITOR_TOOLBAR_HEIGHT; + } + + addDecoration(decoration: model.IModelDeltaDecoration): string { + if (!this._textEditor) { + const id = ++this._lastDecorationId; + const decorationId = `_lazy_${this.id};${id}`; + + this._resolvedDecorations.set(decorationId, { options: decoration }); + return decorationId; + } + + const result = this._textEditor.deltaDecorations([], [decoration]); + this._resolvedDecorations.set(result[0], { id: result[0], options: decoration }); + + return result[0]; + } + + removeDecoration(decorationId: string) { + const realDecorationId = this._resolvedDecorations.get(decorationId); + + if (this._textEditor && realDecorationId && realDecorationId.id !== undefined) { + this._textEditor.deltaDecorations([realDecorationId.id!], []); + } + + // lastly, remove all the cache + this._resolvedDecorations.delete(decorationId); + } + + deltaDecorations(oldDecorations: string[], newDecorations: model.IModelDeltaDecoration[]): string[] { + oldDecorations.forEach(id => { + this.removeDecoration(id); + }); + + const ret = newDecorations.map(option => { + return this.addDecoration(option); + }); + + return ret; + } + + onDeselect() { + this.state = CellState.Preview; + } + + cursorAtBoundary(): CursorAtBoundary { + if (!this._textEditor) { + return CursorAtBoundary.None; + } + + // only validate primary cursor + const selection = this._textEditor.getSelection(); + + // only validate empty cursor + if (!selection || !selection.isEmpty()) { + return CursorAtBoundary.None; + } + + // we don't allow attaching text editor without a model + const lineCnt = this._textEditor.getModel()!.getLineCount(); + + if (selection.startLineNumber === lineCnt) { + // bottom + + if (selection.startLineNumber === 1) { + return CursorAtBoundary.Both; + } else { + return CursorAtBoundary.Bottom; + } + } + + if (selection.startLineNumber === 1) { + return CursorAtBoundary.Top; + } + + return CursorAtBoundary.None; + } + + getMarkdownRenderer() { + if (!this._mdRenderer) { + this._mdRenderer = this._instaService.createInstance(MarkdownRenderer); + } + return this._mdRenderer; + } + + updateOutputHeight(index: number, height: number) { + if (index >= this._outputCollection.length) { + throw new Error('Output index out of range!'); + } + + this._outputCollection[index] = height; + this._ensureOutputsTop(); + this._outputsTop!.changeValue(index, height); + this._onDidChangeTotalHeight.fire(); + } + + getOutputOffset(index: number): number { + if (index >= this._outputCollection.length) { + throw new Error('Output index out of range!'); + } + + this._ensureOutputsTop(); + + return this._outputsTop!.getAccumulatedValue(index - 1); + } + + getOutputHeight(output: IOutput): number | undefined { + let index = this.cell.outputs.indexOf(output); + + if (index < 0) { + return undefined; + } + + if (index < this._outputCollection.length) { + return this._outputCollection[index]; + } + + return undefined; + } + + private getOutputTotalHeight(): number { + this._ensureOutputsTop(); + + return this._outputsTop!.getTotalValue(); + } + + spliceOutputHeights(start: number, deleteCnt: number, heights: number[]) { + this._ensureOutputsTop(); + + this._outputsTop!.removeValues(start, deleteCnt); + if (heights.length) { + const values = new Uint32Array(heights.length); + for (let i = 0; i < heights.length; i++) { + values[i] = heights[i]; + } + + this._outputsTop!.insertValues(start, values); + } + + this._onDidChangeTotalHeight.fire(); + } + + getCellTotalHeight(): number { + if (this.outputs.length) { + return EDITOR_TOOLBAR_HEIGHT + this.editorHeight + EDITOR_TOP_PADDING + EDITOR_BOTTOM_PADDING + 16 + this.getOutputTotalHeight(); + } else { + return EDITOR_TOOLBAR_HEIGHT + this.editorHeight + EDITOR_TOP_PADDING + EDITOR_BOTTOM_PADDING + this.getOutputTotalHeight(); + } + } + + getIndicatorHeight(): number { + return this.getCellTotalHeight() - EDITOR_TOOLBAR_HEIGHT - 16; + } + + protected _ensureOutputsTop(): void { + if (!this._outputsTop) { + const values = new Uint32Array(this._outputCollection.length); + for (let i = 0; i < this._outputCollection.length; i++) { + values[i] = this._outputCollection[i]; + } + + this._outputsTop = new PrefixSumComputer(values); + } + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel.ts new file mode 100644 index 0000000000..4a5b1104ac --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel.ts @@ -0,0 +1,380 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import * as editorCommon from 'vs/editor/common/editorCommon'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { NotebookEditorModel } from 'vs/workbench/contrib/notebook/browser/notebookEditorInput'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { ICell } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IModelDeltaDecoration } from 'vs/editor/common/model'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { CellFindMatch, CellState, ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; +import { Range } from 'vs/editor/common/core/range'; +import { WorkspaceTextEdit } from 'vs/editor/common/modes'; +import { URI } from 'vs/base/common/uri'; +import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; +import { InsertCellEdit, DeleteCellEdit, MoveCellEdit } from 'vs/workbench/contrib/notebook/browser/viewModel/cellEdit'; + +export interface INotebookEditorViewState { + editingCells: { [key: number]: boolean }; + editorViewStates: { [key: number]: editorCommon.ICodeEditorViewState | null }; +} + +export interface ICellModelDecorations { + ownerId: number; + decorations: string[]; +} + +export interface ICellModelDeltaDecorations { + ownerId: number; + decorations: IModelDeltaDecoration[]; +} + +export interface IModelDecorationsChangeAccessor { + deltaDecorations(oldDecorations: ICellModelDecorations[], newDecorations: ICellModelDeltaDecorations[]): ICellModelDecorations[]; +} + +const invalidFunc = () => { throw new Error(`Invalid change accessor`); }; + + +export type NotebookViewCellsSplice = [ + number /* start */, + number /* delete count */, + CellViewModel[] +]; + +export interface INotebookViewCellsUpdateEvent { + synchronous: boolean; + splices: NotebookViewCellsSplice[]; +} + +export class NotebookViewModel extends Disposable { + private _localStore: DisposableStore = this._register(new DisposableStore()); + private _viewCells: CellViewModel[] = []; + + get viewCells(): ICellViewModel[] { + return this._viewCells; + } + + get notebookDocument() { + return this._model.notebook; + } + + get renderers() { + return this._model.notebook!.renderers; + } + + get handle() { + return this._model.notebook.handle; + } + + get languages() { + return this._model.notebook.languages; + } + + get uri() { + return this._model.notebook.uri; + } + + private readonly _onDidChangeViewCells = new Emitter(); + get onDidChangeViewCells(): Event { return this._onDidChangeViewCells.event; } + + private _lastNotebookEditResource: URI[] = []; + + get lastNotebookEditResource(): URI | null { + if (this._lastNotebookEditResource.length) { + return this._lastNotebookEditResource[this._lastNotebookEditResource.length - 1]; + } + return null; + } + + constructor( + public viewType: string, + private _model: NotebookEditorModel, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IBulkEditService private readonly bulkEditService: IBulkEditService, + @IUndoRedoService private readonly undoService: IUndoRedoService + ) { + super(); + + this._register(this._model.onDidChangeCells(e => { + this._onDidChangeViewCells.fire({ + synchronous: true, + splices: e.map(splice => { + return [splice[0], splice[1], splice[2].map(cell => this.instantiationService.createInstance(CellViewModel, this.viewType, this.handle, cell))]; + }) + }); + })); + + this._viewCells = this._model!.notebook!.cells.map(cell => { + const viewCell = this.instantiationService.createInstance(CellViewModel, this.viewType, this._model!.notebook!.handle, cell); + this._localStore.add(viewCell); + return viewCell; + }); + } + + isDirty() { + return this._model.isDirty(); + } + + hide() { + this._viewCells.forEach(cell => { + if (cell.getText() !== '') { + cell.state = CellState.Preview; + } + }); + } + + getViewCellIndex(cell: ICellViewModel) { + return this._viewCells.indexOf(cell as CellViewModel); + } + + private _insertCellDelegate(insertIndex: number, insertCell: CellViewModel) { + this._viewCells!.splice(insertIndex, 0, insertCell); + this._model.insertCell(insertCell.cell, insertIndex); + this._localStore.add(insertCell); + this._onDidChangeViewCells.fire({ synchronous: true, splices: [[insertIndex, 0, [insertCell]]] }); + } + + private _deleteCellDelegate(deleteIndex: number, cell: ICell) { + this._viewCells.splice(deleteIndex, 1); + this._model.deleteCell(deleteIndex); + this._onDidChangeViewCells.fire({ synchronous: true, splices: [[deleteIndex, 1, []]] }); + } + + insertCell(index: number, cell: ICell, synchronous: boolean): CellViewModel { + const newCell = this.instantiationService.createInstance(CellViewModel, this.viewType, this.handle, cell); + this._viewCells!.splice(index, 0, newCell); + this._model.insertCell(newCell.cell, index); + this._localStore.add(newCell); + this.undoService.pushElement(new InsertCellEdit(this.uri, index, newCell, { + insertCell: this._insertCellDelegate.bind(this), + deleteCell: this._deleteCellDelegate.bind(this) + })); + + this._onDidChangeViewCells.fire({ synchronous: synchronous, splices: [[index, 0, [newCell]]] }); + return newCell; + } + + deleteCell(index: number, synchronous: boolean) { + let viewCell = this._viewCells[index]; + this._viewCells.splice(index, 1); + this._model.deleteCell(index); + + this.undoService.pushElement(new DeleteCellEdit(this.uri, index, viewCell, { + insertCell: this._insertCellDelegate.bind(this), + deleteCell: this._deleteCellDelegate.bind(this) + }, this.instantiationService, this)); + + this._onDidChangeViewCells.fire({ synchronous: synchronous, splices: [[index, 1, []]] }); + viewCell.dispose(); + } + + moveCellToIdx(index: number, newIdx: number, synchronous: boolean, pushedToUndoStack: boolean = true): boolean { + const viewCell = this.viewCells[index] as CellViewModel; + if (!viewCell) { + return false; + } + + this.viewCells.splice(index, 1); + this._model.deleteCell(index); + + this.viewCells!.splice(newIdx, 0, viewCell); + this._model.insertCell(viewCell.cell, newIdx); + + if (pushedToUndoStack) { + this.undoService.pushElement(new MoveCellEdit(this.uri, index, newIdx, { + moveCell: (fromIndex: number, toIndex: number) => { + this.moveCellToIdx(fromIndex, toIndex, true, false); + } + })); + } + + this._onDidChangeViewCells.fire({ synchronous: synchronous, splices: [[index, 1, []]] }); + this._onDidChangeViewCells.fire({ synchronous: synchronous, splices: [[newIdx, 0, [viewCell]]] }); + + return true; + } + + saveEditorViewState(): INotebookEditorViewState { + const state: { [key: number]: boolean } = {}; + this._viewCells.filter(cell => cell.state === CellState.Editing).forEach(cell => state[cell.cell.handle] = true); + const editorViewStates: { [key: number]: editorCommon.ICodeEditorViewState } = {}; + this._viewCells.map(cell => ({ handle: cell.cell.handle, state: cell.saveEditorViewState() })).forEach(viewState => { + if (viewState.state) { + editorViewStates[viewState.handle] = viewState.state; + } + }); + + return { + editingCells: state, + editorViewStates: editorViewStates + }; + } + + restoreEditorViewState(viewState: INotebookEditorViewState | undefined): void { + if (!viewState) { + return; + } + + this._viewCells.forEach(cell => { + const isEditing = viewState.editingCells && viewState.editingCells[cell.handle]; + const editorViewState = viewState.editorViewStates && viewState.editorViewStates[cell.handle]; + + cell.state = isEditing ? CellState.Editing : CellState.Preview; + cell.restoreEditorViewState(editorViewState); + }); + } + + /** + * Editor decorations across cells. For example, find decorations for multiple code cells + * The reason that we can't completely delegate this to CodeEditorWidget is most of the time, the editors for cells are not created yet but we already have decorations for them. + */ + changeDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => T): T | null { + const changeAccessor: IModelDecorationsChangeAccessor = { + deltaDecorations: (oldDecorations: ICellModelDecorations[], newDecorations: ICellModelDeltaDecorations[]): ICellModelDecorations[] => { + return this.deltaDecorationsImpl(oldDecorations, newDecorations); + } + }; + + let result: T | null = null; + try { + result = callback(changeAccessor); + } catch (e) { + onUnexpectedError(e); + } + + changeAccessor.deltaDecorations = invalidFunc; + + return result; + } + + deltaDecorationsImpl(oldDecorations: ICellModelDecorations[], newDecorations: ICellModelDeltaDecorations[]): ICellModelDecorations[] { + + const mapping = new Map(); + oldDecorations.forEach(oldDecoration => { + const ownerId = oldDecoration.ownerId; + + if (!mapping.has(ownerId)) { + const cell = this._viewCells.find(cell => cell.handle === ownerId); + if (cell) { + mapping.set(ownerId, { cell: cell, oldDecorations: [], newDecorations: [] }); + } + } + + const data = mapping.get(ownerId)!; + if (data) { + data.oldDecorations = oldDecoration.decorations; + } + }); + + newDecorations.forEach(newDecoration => { + const ownerId = newDecoration.ownerId; + + if (!mapping.has(ownerId)) { + const cell = this._viewCells.find(cell => cell.handle === ownerId); + + if (cell) { + mapping.set(ownerId, { cell: cell, oldDecorations: [], newDecorations: [] }); + } + } + + const data = mapping.get(ownerId)!; + if (data) { + data.newDecorations = newDecoration.decorations; + } + }); + + const ret: ICellModelDecorations[] = []; + mapping.forEach((value, ownerId) => { + const cellRet = value.cell.deltaDecorations(value.oldDecorations, value.newDecorations); + ret.push({ + ownerId: ownerId, + decorations: cellRet + }); + }); + + return ret; + } + + + /** + * Search in notebook text model + * @param value + */ + find(value: string): CellFindMatch[] { + const matches: CellFindMatch[] = []; + this._viewCells.forEach(cell => { + const cellMatches = cell.startFind(value); + if (cellMatches) { + matches.push(cellMatches); + } + }); + + return matches; + } + + replaceOne(cell: ICellViewModel, range: Range, text: string): Promise { + const viewCell = cell as CellViewModel; + this._lastNotebookEditResource.push(viewCell.uri); + return viewCell.resolveTextModel().then(() => { + this.bulkEditService.apply({ edits: [{ edit: { range: range, text: text }, resource: cell.uri }] }, { quotableLabel: 'Notebook Replace' }); + }); + } + + async replaceAll(matches: CellFindMatch[], text: string): Promise { + if (!matches.length) { + return; + } + + let textEdits: WorkspaceTextEdit[] = []; + this._lastNotebookEditResource.push(matches[0].cell.uri); + + matches.forEach(match => { + match.matches.forEach(singleMatch => { + textEdits.push({ + edit: { range: singleMatch.range, text: text }, + resource: match.cell.uri + }); + }); + }); + + return Promise.all(matches.map(match => { + return match.cell.resolveTextModel(); + })).then(async () => { + this.bulkEditService.apply({ edits: textEdits }, { quotableLabel: 'Notebook Replace All' }); + return; + }); + } + + canUndo(): boolean { + return this.undoService.canUndo(this.uri); + } + + undo() { + this.undoService.undo(this.uri); + } + + redo() { + this.undoService.redo(this.uri); + } + + equal(model: NotebookEditorModel) { + return this._model === model; + } + + dispose() { + this._localStore.clear(); + this._viewCells.forEach(cell => { + cell.save(); + cell.dispose(); + }); + + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts new file mode 100644 index 0000000000..7fa2dde913 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { ICell, IOutput, NotebookCellOutputsSplice, CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { PieceTreeTextBufferFactory, PieceTreeTextBufferBuilder } from 'vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder'; +import { URI } from 'vs/base/common/uri'; + +export class NotebookCellTextModel implements ICell { + private _onDidChangeOutputs = new Emitter(); + onDidChangeOutputs: Event = this._onDidChangeOutputs.event; + + private _onDidChangeContent = new Emitter(); + onDidChangeContent: Event = this._onDidChangeContent.event; + + private _outputs: IOutput[]; + + get outputs(): IOutput[] { + return this._outputs; + } + + get source() { + return this._source; + } + + set source(newValue: string[]) { + this._source = newValue; + this._buffer = null; + } + + private _buffer: PieceTreeTextBufferFactory | null = null; + + constructor( + readonly uri: URI, + public handle: number, + private _source: string[], + public language: string, + public cellKind: CellKind, + outputs: IOutput[] + ) { + this._outputs = outputs; + } + + contentChange() { + this._onDidChangeContent.fire(); + + } + + spliceNotebookCellOutputs(splices: NotebookCellOutputsSplice[]): void { + splices.reverse().forEach(splice => { + this.outputs.splice(splice[0], splice[1], ...splice[2]); + }); + + this._onDidChangeOutputs.fire(splices); + } + + resolveTextBufferFactory(): PieceTreeTextBufferFactory { + if (this._buffer) { + return this._buffer; + } + + let builder = new PieceTreeTextBufferBuilder(); + builder.acceptChunk(this.source.join('\n')); + this._buffer = builder.finish(true); + return this._buffer; + } +} diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts new file mode 100644 index 0000000000..cb7ed47fa7 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; +import { INotebookTextModel, NotebookCellOutputsSplice, NotebookCellsSplice } from 'vs/workbench/contrib/notebook/common/notebookCommon'; + +export class NotebookTextModel extends Disposable implements INotebookTextModel { + private readonly _onWillDispose: Emitter = this._register(new Emitter()); + readonly onWillDispose: Event = this._onWillDispose.event; + private readonly _onDidChangeCells = new Emitter(); + get onDidChangeCells(): Event { return this._onDidChangeCells.event; } + private _onDidChangeContent = new Emitter(); + onDidChangeContent: Event = this._onDidChangeContent.event; + private _mapping: Map = new Map(); + private _cellListeners: Map = new Map(); + cells: NotebookCellTextModel[]; + activeCell: NotebookCellTextModel | undefined; + languages: string[] = []; + renderers = new Set(); + + constructor( + public handle: number, + public viewType: string, + public uri: URI + ) { + super(); + this.cells = []; + } + + updateLanguages(languages: string[]) { + this.languages = languages; + } + + updateRenderers(renderers: number[]) { + renderers.forEach(render => { + this.renderers.add(render); + }); + } + + updateActiveCell(handle: number) { + this.activeCell = this._mapping.get(handle); + } + + insertNewCell(index: number, cell: NotebookCellTextModel): void { + this._mapping.set(cell.handle, cell); + this.cells.splice(index, 0, cell); + let dirtyStateListener = cell.onDidChangeContent(() => { + this._onDidChangeContent.fire(); + }); + + this._cellListeners.set(cell.handle, dirtyStateListener); + this._onDidChangeContent.fire(); + return; + } + + removeCell(index: number) { + let cell = this.cells[index]; + this._cellListeners.get(cell.handle)?.dispose(); + this._cellListeners.delete(cell.handle); + this.cells.splice(index, 1); + this._onDidChangeContent.fire(); + } + + + // TODO@rebornix should this trigger content change event? + $spliceNotebookCells(splices: NotebookCellsSplice[]): void { + splices.reverse().forEach(splice => { + let cellDtos = splice[2]; + let newCells = cellDtos.map(cell => { + let mainCell = new NotebookCellTextModel(URI.revive(cell.uri), cell.handle, cell.source, cell.language, cell.cellKind, cell.outputs || []); + this._mapping.set(cell.handle, mainCell); + let dirtyStateListener = mainCell.onDidChangeContent(() => { + this._onDidChangeContent.fire(); + }); + this._cellListeners.set(cell.handle, dirtyStateListener); + return mainCell; + }); + + this.cells.splice(splice[0], splice[1], ...newCells); + }); + + this._onDidChangeCells.fire(splices); + } + + // TODO@rebornix should this trigger content change event? + $spliceNotebookCellOutputs(cellHandle: number, splices: NotebookCellOutputsSplice[]): void { + let cell = this._mapping.get(cellHandle); + cell?.spliceNotebookCellOutputs(splices); + } + + dispose() { + this._onWillDispose.fire(); + this._cellListeners.forEach(val => val.dispose()); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts new file mode 100644 index 0000000000..357b89c732 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import * as glob from 'vs/base/common/glob'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { isWindows } from 'vs/base/common/platform'; +import { ISplice } from 'vs/base/common/sequence'; +import { URI } from 'vs/base/common/uri'; +import * as editorCommon from 'vs/editor/common/editorCommon'; +import { PieceTreeTextBufferFactory } from 'vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; + +export enum CellKind { + Markdown = 1, + Code = 2 +} + +export enum CellOutputKind { + Text = 1, + Error = 2, + Rich = 3 +} + +export const NOTEBOOK_DISPLAY_ORDER = [ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain' +]; + +export interface INotebookDisplayOrder { + defaultOrder: string[]; + userOrder?: string[]; +} + +export interface INotebookMimeTypeSelector { + type: string; + subTypes?: string[]; +} + +export interface INotebookRendererInfo { + id: ExtensionIdentifier; + extensionLocation: URI, + preloads: URI[] +} + +export interface INotebookSelectors { + readonly filenamePattern?: string; +} + +export interface IStreamOutput { + outputKind: CellOutputKind.Text; + text: string; +} + +export interface IErrorOutput { + outputKind: CellOutputKind.Error; + /** + * Exception Name + */ + ename?: string; + /** + * Exception Value + */ + evalue?: string; + /** + * Exception call stacks + */ + traceback?: string[]; +} + +export interface IDisplayOutput { + outputKind: CellOutputKind.Rich; + /** + * { mime_type: value } + */ + data: { [key: string]: any; } +} + +export enum MimeTypeRendererResolver { + Core, + Active, + Lazy +} + +export interface IOrderedMimeType { + mimeType: string; + isResolved: boolean; + rendererId?: number; + output?: string; +} + +export interface ITransformedDisplayOutputDto { + outputKind: CellOutputKind.Rich; + data: { [key: string]: any; } + + orderedMimeTypes: IOrderedMimeType[]; + pickedMimeTypeIndex: number; +} + +export interface IGenericOutput { + outputKind: CellOutputKind; + pickedMimeType?: string; + pickedRenderer?: number; + transformedOutput?: { [key: string]: IDisplayOutput }; +} + +export type IOutput = ITransformedDisplayOutputDto | IStreamOutput | IErrorOutput; + +export interface ICell { + readonly uri: URI; + handle: number; + source: string[]; + language: string; + cellKind: CellKind; + outputs: IOutput[]; + onDidChangeOutputs?: Event; + resolveTextBufferFactory(): PieceTreeTextBufferFactory; + // TODO@rebornix it should be later on replaced by moving textmodel resolution into CellTextModel + contentChange(): void; +} + +export interface LanguageInfo { + file_extension: string; +} + +export interface IMetadata { + language_info: LanguageInfo; +} + +export interface INotebookTextModel { + handle: number; + viewType: string; + // metadata: IMetadata; + readonly uri: URI; + languages: string[]; + cells: ICell[]; + renderers: Set; + onDidChangeCells?: Event; + onDidChangeContent: Event; + onWillDispose(listener: () => void): IDisposable; +} + +export interface IRenderOutput { + shadowContent?: string; + hasDynamicHeight: boolean; +} + +export type NotebookCellsSplice = [ + number /* start */, + number /* delete count */, + ICell[] +]; + +export type NotebookCellOutputsSplice = [ + number /* start */, + number /* delete count */, + IOutput[] +]; + +export namespace CellUri { + + export const scheme = 'vscode-notebook'; + + export function generate(notebook: URI, handle: number): URI { + return notebook.with({ + query: JSON.stringify({ cell: handle, notebook: notebook.toString() }), + scheme, + }); + } + + export function parse(cell: URI): { notebook: URI, handle: number } | undefined { + if (cell.scheme !== scheme) { + return undefined; + } + try { + const data = <{ cell: number, notebook: string }>JSON.parse(cell.query); + return { + handle: data.cell, + notebook: URI.parse(data.notebook) + }; + } catch { + return undefined; + } + } +} + +export function mimeTypeSupportedByCore(mimeType: string) { + if ([ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain', + 'text/x-javascript' + ].indexOf(mimeType) > -1) { + return true; + } + + return false; +} + +// if (isWindows) { +// value = value.replace(/\//g, '\\'); +// } + +function matchGlobUniversal(pattern: string, path: string) { + if (isWindows) { + pattern = pattern.replace(/\//g, '\\'); + path = path.replace(/\//g, '\\'); + } + + return glob.match(pattern, path); +} + + +function getMimeTypeOrder(mimeType: string, userDisplayOrder: string[], documentDisplayOrder: string[], defaultOrder: string[]) { + let order = 0; + for (let i = 0; i < userDisplayOrder.length; i++) { + if (matchGlobUniversal(userDisplayOrder[i], mimeType)) { + return order; + } + order++; + } + + for (let i = 0; i < documentDisplayOrder.length; i++) { + if (matchGlobUniversal(documentDisplayOrder[i], mimeType)) { + return order; + } + + order++; + } + + for (let i = 0; i < defaultOrder.length; i++) { + if (matchGlobUniversal(defaultOrder[i], mimeType)) { + return order; + } + + order++; + } + + return order; +} + +export function sortMimeTypes(mimeTypes: string[], userDisplayOrder: string[], documentDisplayOrder: string[], defaultOrder: string[]) { + const sorted = mimeTypes.sort((a, b) => { + return getMimeTypeOrder(a, userDisplayOrder, documentDisplayOrder, defaultOrder) - getMimeTypeOrder(b, userDisplayOrder, documentDisplayOrder, defaultOrder); + }); + + return sorted; +} + +interface IMutableSplice extends ISplice { + deleteCount: number; +} + +export function diff(before: T[], after: T[], contains: (a: T) => boolean): ISplice[] { + const result: IMutableSplice[] = []; + + 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]; + + if (beforeElement === afterElement) { + // equal + beforeIdx += 1; + afterIdx += 1; + continue; + } + + if (contains(afterElement)) { + // `afterElement` exists before, which means some elements before `afterElement` are deleted + pushSplice(beforeIdx, 1, []); + beforeIdx += 1; + } else { + // `afterElement` added + pushSplice(beforeIdx, 0, [afterElement]); + afterIdx += 1; + } + } + + return result; +} + +export interface ICellEditorViewState { + selections: editorCommon.ICursorState[]; +} + +export const NOTEBOOK_EDITOR_CURSOR_BOUNDARY = new RawContextKey<'none' | 'top' | 'bottom' | 'both'>('notebookEditorCursorAtBoundary', 'none'); diff --git a/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts new file mode 100644 index 0000000000..8ab0ddf800 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as glob from 'vs/base/common/glob'; + +export class NotebookOutputRendererInfo { + + readonly id: string; + readonly displayName: string; + readonly mimeTypes: readonly string[]; + readonly mimeTypeGlobs: glob.ParsedPattern[]; + + constructor(descriptor: { + readonly id: string; + readonly displayName: string; + readonly mimeTypes: readonly string[]; + }) { + this.id = descriptor.id; + this.displayName = descriptor.displayName; + this.mimeTypes = descriptor.mimeTypes; + this.mimeTypeGlobs = this.mimeTypes.map(pattern => glob.parse(pattern)); + } + + matches(mimeType: string) { + let matched = this.mimeTypeGlobs.find(pattern => pattern(mimeType)); + return matched; + } +} diff --git a/src/vs/workbench/contrib/notebook/common/notebookProvider.ts b/src/vs/workbench/contrib/notebook/common/notebookProvider.ts new file mode 100644 index 0000000000..ed3c31dd99 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/notebookProvider.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as glob from 'vs/base/common/glob'; +import { URI } from 'vs/base/common/uri'; +import { basename } from 'vs/base/common/resources'; + +export interface NotebookSelector { + readonly filenamePattern?: string; + readonly excludeFileNamePattern?: string; +} + +export class NotebookProviderInfo { + + readonly id: string; + readonly displayName: string; + readonly selector: readonly NotebookSelector[]; + + constructor(descriptor: { + readonly id: string; + readonly displayName: string; + readonly selector: readonly NotebookSelector[]; + }) { + this.id = descriptor.id; + this.displayName = descriptor.displayName; + this.selector = descriptor.selector; + } + + matches(resource: URI): boolean { + return this.selector.some(selector => NotebookProviderInfo.selectorMatches(selector, resource)); + } + + static selectorMatches(selector: NotebookSelector, resource: URI): boolean { + if (selector.filenamePattern) { + if (glob.match(selector.filenamePattern.toLowerCase(), basename(resource).toLowerCase())) { + if (selector.excludeFileNamePattern) { + if (glob.match(selector.excludeFileNamePattern.toLowerCase(), basename(resource).toLowerCase())) { + // should exclude + + return false; + } + } + return true; + } + } + return false; + } +} diff --git a/src/vs/workbench/contrib/notebook/test/notebookCommon.test.ts b/src/vs/workbench/contrib/notebook/test/notebookCommon.test.ts new file mode 100644 index 0000000000..8bedd3cd08 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/test/notebookCommon.test.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { NOTEBOOK_DISPLAY_ORDER, sortMimeTypes, CellKind, diff, CellUri } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { TestCell } from 'vs/workbench/contrib/notebook/test/testNotebookEditor'; +import { URI } from 'vs/base/common/uri'; + +suite('NotebookCommon', () => { + test('sortMimeTypes default orders', function () { + const defaultDisplayOrder = NOTEBOOK_DISPLAY_ORDER; + + assert.deepEqual(sortMimeTypes( + [ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain' + ], [], [], defaultDisplayOrder), + [ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain' + ] + ); + + assert.deepEqual(sortMimeTypes( + [ + 'application/json', + 'text/markdown', + 'application/javascript', + 'text/html', + 'text/plain', + 'image/png', + 'image/jpeg', + 'image/svg+xml' + ], [], [], defaultDisplayOrder), + [ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain' + ] + ); + + assert.deepEqual(sortMimeTypes( + [ + 'text/markdown', + 'application/json', + 'text/plain', + 'image/jpeg', + 'application/javascript', + 'text/html', + 'image/png', + 'image/svg+xml' + ], [], [], defaultDisplayOrder), + [ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain' + ] + ); + }); + + test('sortMimeTypes document orders', function () { + const defaultDisplayOrder = NOTEBOOK_DISPLAY_ORDER; + assert.deepEqual(sortMimeTypes( + [ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain' + ], [], + [ + 'text/markdown', + 'text/html', + 'application/json' + ], defaultDisplayOrder), + [ + 'text/markdown', + 'text/html', + 'application/json', + 'application/javascript', + 'image/svg+xml', + 'image/png', + 'image/jpeg', + 'text/plain' + ] + ); + + assert.deepEqual(sortMimeTypes( + [ + 'text/markdown', + 'application/json', + 'text/plain', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'image/jpeg', + 'image/png' + ], [], + [ + 'text/html', + 'text/markdown', + 'application/json' + ], defaultDisplayOrder), + [ + 'text/html', + 'text/markdown', + 'application/json', + 'application/javascript', + 'image/svg+xml', + 'image/png', + 'image/jpeg', + 'text/plain' + ] + ); + }); + + test('sortMimeTypes user orders', function () { + const defaultDisplayOrder = NOTEBOOK_DISPLAY_ORDER; + assert.deepEqual(sortMimeTypes( + [ + 'application/json', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'text/markdown', + 'image/png', + 'image/jpeg', + 'text/plain' + ], + [ + 'image/png', + 'text/plain', + ], + [ + 'text/markdown', + 'text/html', + 'application/json' + ], defaultDisplayOrder), + [ + 'image/png', + 'text/plain', + 'text/markdown', + 'text/html', + 'application/json', + 'application/javascript', + 'image/svg+xml', + 'image/jpeg', + ] + ); + + assert.deepEqual(sortMimeTypes( + [ + 'text/markdown', + 'application/json', + 'text/plain', + 'application/javascript', + 'text/html', + 'image/svg+xml', + 'image/jpeg', + 'image/png' + ], + [ + 'application/json', + 'text/html', + ], + [ + 'text/html', + 'text/markdown', + 'application/json' + ], defaultDisplayOrder), + [ + 'application/json', + 'text/html', + 'text/markdown', + 'application/javascript', + 'image/svg+xml', + 'image/png', + 'image/jpeg', + 'text/plain' + ] + ); + }); + + test('sortMimeTypes glob', function () { + const defaultDisplayOrder = NOTEBOOK_DISPLAY_ORDER; + + // unknown mime types come last + assert.deepEqual(sortMimeTypes( + [ + 'application/json', + 'application/vnd-vega.json', + 'application/vnd-plot.json', + 'application/javascript', + 'text/html' + ], [], + [ + 'text/markdown', + 'text/html', + 'application/json' + ], defaultDisplayOrder), + [ + 'text/html', + 'application/json', + 'application/javascript', + 'application/vnd-vega.json', + 'application/vnd-plot.json' + ], + 'unknown mimetypes keep the ordering' + ); + + assert.deepEqual(sortMimeTypes( + [ + 'application/json', + 'application/javascript', + 'text/html', + 'application/vnd-plot.json', + 'application/vnd-vega.json' + ], [], + [ + 'application/vnd-vega*', + 'text/markdown', + 'text/html', + 'application/json' + ], defaultDisplayOrder), + [ + 'application/vnd-vega.json', + 'text/html', + 'application/json', + 'application/javascript', + 'application/vnd-plot.json' + ], + 'glob *' + ); + }); + + test('diff cells', function () { + const cells: TestCell[] = []; + + for (let i = 0; i < 5; i++) { + cells.push( + new TestCell('notebook', i, [`var a = ${i};`], 'javascript', CellKind.Code, []) + ); + } + + assert.deepEqual(diff(cells, [], (cell) => { + return cells.indexOf(cell) > -1; + }), [ + { + start: 0, + deleteCount: 5, + toInsert: [] + } + ] + ); + + assert.deepEqual(diff([], cells, (cell) => { + return false; + }), [ + { + start: 0, + deleteCount: 0, + toInsert: cells + } + ] + ); + + const cellA = new TestCell('notebook', 6, ['var a = 6;'], 'javascript', CellKind.Code, []); + const cellB = new TestCell('notebook', 7, ['var a = 7;'], 'javascript', CellKind.Code, []); + + const modifiedCells = [ + cells[0], + cells[1], + cellA, + cells[3], + cellB, + cells[4] + ]; + + const splices = diff(cells, modifiedCells, (cell) => { + return cells.indexOf(cell) > -1; + }); + + assert.deepEqual(splices, + [ + { + start: 2, + deleteCount: 1, + toInsert: [cellA] + }, + { + start: 4, + deleteCount: 0, + toInsert: [cellB] + } + ] + ); + }); +}); + + +suite('CellUri', function () { + + test('parse, generate', function () { + + const nb = URI.parse('foo:///bar/følder/file.nb'); + const id = 17; + + const data = CellUri.generate(nb, id); + const actual = CellUri.parse(data); + assert.ok(Boolean(actual)); + assert.equal(actual?.handle, id); + assert.equal(actual?.notebook.toString(), nb.toString()); + }); +}); diff --git a/src/vs/workbench/contrib/notebook/test/notebookViewModel.test.ts b/src/vs/workbench/contrib/notebook/test/notebookViewModel.test.ts new file mode 100644 index 0000000000..ff48e2bbfc --- /dev/null +++ b/src/vs/workbench/contrib/notebook/test/notebookViewModel.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from 'vs/base/common/uri'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { NotebookEditorModel } from 'vs/workbench/contrib/notebook/browser/notebookEditorInput'; +import { NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; +import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { withTestNotebook, TestCell } from 'vs/workbench/contrib/notebook/test/testNotebookEditor'; +import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; +import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; +import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; + +suite('NotebookViewModel', () => { + const instantiationService = new TestInstantiationService(); + const blukEditService = instantiationService.get(IBulkEditService); + const undoRedoService = instantiationService.stub(IUndoRedoService, () => { }); + instantiationService.spy(IUndoRedoService, 'pushElement'); + + test('ctor', function () { + const notebook = new NotebookTextModel(0, 'notebook', URI.parse('test')); + const model = new NotebookEditorModel(notebook); + const viewModel = new NotebookViewModel('notebook', model, instantiationService, blukEditService, undoRedoService); + assert.equal(viewModel.viewType, 'notebook'); + }); + + test('insert/delete', function () { + withTestNotebook( + instantiationService, + blukEditService, + undoRedoService, + [ + [['var a = 1;'], 'javascript', CellKind.Code, []], + [['var b = 2;'], 'javascript', CellKind.Code, []] + ], + (editor, viewModel) => { + const cell = viewModel.insertCell(1, new TestCell(viewModel.viewType, 0, ['var c = 3;'], 'javascript', CellKind.Code, []), true); + assert.equal(viewModel.viewCells.length, 3); + assert.equal(viewModel.notebookDocument.cells.length, 3); + assert.equal(viewModel.getViewCellIndex(cell), 1); + + viewModel.deleteCell(1, true); + assert.equal(viewModel.viewCells.length, 2); + assert.equal(viewModel.notebookDocument.cells.length, 2); + assert.equal(viewModel.getViewCellIndex(cell), -1); + } + ); + }); + + test('index', function () { + withTestNotebook( + instantiationService, + blukEditService, + undoRedoService, + [ + [['var a = 1;'], 'javascript', CellKind.Code, []], + [['var b = 2;'], 'javascript', CellKind.Code, []] + ], + (editor, viewModel) => { + const firstViewCell = viewModel.viewCells[0]; + const lastViewCell = viewModel.viewCells[viewModel.viewCells.length - 1]; + + const insertIndex = viewModel.getViewCellIndex(firstViewCell) + 1; + const cell = viewModel.insertCell(insertIndex, new TestCell(viewModel.viewType, 3, ['var c = 3;'], 'javascript', CellKind.Code, []), true); + + const addedCellIndex = viewModel.getViewCellIndex(cell); + viewModel.deleteCell(addedCellIndex, true); + + const secondInsertIndex = viewModel.getViewCellIndex(lastViewCell) + 1; + const cell2 = viewModel.insertCell(secondInsertIndex, new TestCell(viewModel.viewType, 4, ['var d = 4;'], 'javascript', CellKind.Code, []), true); + + assert.equal(viewModel.viewCells.length, 3); + assert.equal(viewModel.notebookDocument.cells.length, 3); + assert.equal(viewModel.getViewCellIndex(cell2), 2); + } + ); + }); +}); diff --git a/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts new file mode 100644 index 0000000000..791cf3c161 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; +import { PieceTreeTextBufferFactory } from 'vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder'; +import { CellKind, ICell, IOutput, NotebookCellOutputsSplice, CellUri } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { NotebookViewModel, IModelDecorationsChangeAccessor } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookCellViewModel'; +import { NotebookEditorModel } from 'vs/workbench/contrib/notebook/browser/notebookEditorInput'; +import { INotebookEditor, NotebookLayoutInfo } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; +import { OutputRenderer } from 'vs/workbench/contrib/notebook/browser/view/output/outputRenderer'; +import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; +import { Range } from 'vs/editor/common/core/range'; +import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; +import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; +import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; +import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; + +export class TestCell implements ICell { + uri: URI; + private _onDidChangeOutputs = new Emitter(); + onDidChangeOutputs: Event = this._onDidChangeOutputs.event; + private _isDirty: boolean = false; + private _outputs: IOutput[]; + get outputs(): IOutput[] { + return this._outputs; + } + + get isDirty() { + return this._isDirty; + } + + set isDirty(newState: boolean) { + this._isDirty = newState; + + } + + constructor( + public viewType: string, + public handle: number, + public source: string[], + public language: string, + public cellKind: CellKind, + outputs: IOutput[] + ) { + this._outputs = outputs; + this.uri = CellUri.generate(URI.parse('test:///fake/notebook'), handle); + } + contentChange(): void { + // throw new Error('Method not implemented.'); + } + + resolveTextBufferFactory(): PieceTreeTextBufferFactory { + throw new Error('Method not implemented.'); + } +} + +export class TestNotebookEditor implements INotebookEditor { + + get viewModel() { + return undefined; + } + + constructor( + ) { } + + setCellSelection(cell: CellViewModel, selection: Range): void { + throw new Error('Method not implemented.'); + } + + selectElement(cell: CellViewModel): void { + throw new Error('Method not implemented.'); + } + + moveCellDown(cell: CellViewModel): void { + throw new Error('Method not implemented.'); + } + + moveCellUp(cell: CellViewModel): void { + throw new Error('Method not implemented.'); + } + + setSelection(cell: CellViewModel, selection: Range): void { + throw new Error('Method not implemented.'); + } + revealRangeInView(cell: CellViewModel, range: Range): void { + throw new Error('Method not implemented.'); + } + revealRangeInCenter(cell: CellViewModel, range: Range): void { + throw new Error('Method not implemented.'); + } + revealRangeInCenterIfOutsideViewport(cell: CellViewModel, range: Range): void { + throw new Error('Method not implemented.'); + } + + revealLineInView(cell: CellViewModel, line: number): void { + throw new Error('Method not implemented.'); + } + getLayoutInfo(): NotebookLayoutInfo { + throw new Error('Method not implemented.'); + } + revealLineInCenterIfOutsideViewport(cell: CellViewModel, line: number): void { + throw new Error('Method not implemented.'); + } + revealLineInCenter(cell: CellViewModel, line: number): void { + throw new Error('Method not implemented.'); + } + focus(): void { + throw new Error('Method not implemented.'); + } + showFind(): void { + throw new Error('Method not implemented.'); + } + hideFind(): void { + throw new Error('Method not implemented.'); + } + revealInView(cell: CellViewModel): void { + throw new Error('Method not implemented.'); + } + revealInCenter(cell: CellViewModel): void { + throw new Error('Method not implemented.'); + } + revealInCenterIfOutsideViewport(cell: CellViewModel): void { + throw new Error('Method not implemented.'); + } + async insertNotebookCell(cell: CellViewModel, type: CellKind, direction: 'above' | 'below'): Promise { + // throw new Error('Method not implemented.'); + } + deleteNotebookCell(cell: CellViewModel): void { + // throw new Error('Method not implemented.'); + } + editNotebookCell(cell: CellViewModel): void { + // throw new Error('Method not implemented.'); + } + saveNotebookCell(cell: CellViewModel): void { + // throw new Error('Method not implemented.'); + } + focusNotebookCell(cell: CellViewModel, focusEditor: boolean): void { + // throw new Error('Method not implemented.'); + } + getActiveCell(): CellViewModel | undefined { + // throw new Error('Method not implemented.'); + return undefined; // {{SQL CARBON EDIT}} strict-null-check + } + layoutNotebookCell(cell: CellViewModel, height: number): void { + // throw new Error('Method not implemented.'); + } + createInset(cell: CellViewModel, output: IOutput, shadowContent: string, offset: number): void { + // throw new Error('Method not implemented.'); + } + removeInset(output: IOutput): void { + // throw new Error('Method not implemented.'); + } + triggerScroll(event: IMouseWheelEvent): void { + // throw new Error('Method not implemented.'); + } + getFontInfo(): BareFontInfo | undefined { + return BareFontInfo.createFromRawSettings({ + fontFamily: 'Monaco', + }, 1, true); + } + getOutputRenderer(): OutputRenderer { + throw new Error('Method not implemented.'); + } + + changeDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => any): any { + throw new Error('Method not implemented.'); + } +} + +export function createTestCellViewModel(instantiationService: IInstantiationService, viewType: string, notebookHandle: number, cellhandle: number, source: string[], language: string, cellKind: CellKind, outputs: IOutput[]) { + const mockCell = new TestCell(viewType, cellhandle, source, language, cellKind, outputs); + return instantiationService.createInstance(CellViewModel, viewType, notebookHandle, mockCell); +} + +export function withTestNotebook(instantiationService: IInstantiationService, blukEditService: IBulkEditService, undoRedoService: IUndoRedoService, cells: [string[], string, CellKind, IOutput[]][], callback: (editor: TestNotebookEditor, viewModel: NotebookViewModel) => void) { + const viewType = 'notebook'; + const editor = new TestNotebookEditor(); + const notebook = new NotebookTextModel(0, viewType, URI.parse('test')); + notebook.cells = cells.map((cell, index) => { + return new NotebookCellTextModel(notebook.uri, index, cell[0], cell[1], cell[2], cell[3]); + }); + const model = new NotebookEditorModel(notebook); + const viewModel = new NotebookViewModel(viewType, model, instantiationService, blukEditService, undoRedoService); + + callback(editor, viewModel); + + viewModel.dispose(); + return; +} diff --git a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts new file mode 100644 index 0000000000..5ebf55b654 --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts @@ -0,0 +1,472 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/anythingQuickAccess'; +import { IQuickPickSeparator, IQuickInputButton, IKeyMods, quickPickItemScorerAccessor, QuickPickItemScorerAccessor, IQuickPick } from 'vs/platform/quickinput/common/quickInput'; +import { IPickerQuickAccessItem, PickerQuickAccessProvider, TriggerAction, FastAndSlowPicksType } from 'vs/platform/quickinput/browser/pickerQuickAccess'; +import { prepareQuery, IPreparedQuery, compareItemsByScore, scoreItem, ScorerCache } from 'vs/base/common/fuzzyScorer'; +import { IFileQueryBuilderOptions, QueryBuilder } from 'vs/workbench/contrib/search/common/queryBuilder'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { getOutOfWorkspaceEditorResources, extractRangeFromFilter, IWorkbenchSearchConfiguration } from 'vs/workbench/contrib/search/common/search'; +import { ISearchService, IFileMatch } from 'vs/workbench/services/search/common/search'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { untildify } from 'vs/base/common/labels'; +import { IRemotePathService } from 'vs/workbench/services/path/common/remotePathService'; +import { URI } from 'vs/base/common/uri'; +import { toLocalResource, dirname, basenameOrAuthority } from 'vs/base/common/resources'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { IFileService } from 'vs/platform/files/common/files'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { getIconClasses } from 'vs/editor/common/services/getIconClasses'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { localize } from 'vs/nls'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IWorkbenchEditorConfiguration, IEditorInput, EditorInput } from 'vs/workbench/common/editor'; +import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from 'vs/workbench/services/editor/common/editorService'; +import { Range, IRange } from 'vs/editor/common/core/range'; +import { ThrottledDelayer } from 'vs/base/common/async'; +import { top } from 'vs/base/common/arrays'; +import { FileQueryCacheState } from 'vs/workbench/contrib/search/common/cacheState'; +import { IHistoryService } from 'vs/workbench/services/history/common/history'; +import { IResourceEditorInput, ITextEditorOptions } from 'vs/platform/editor/common/editor'; +import { Schemas } from 'vs/base/common/network'; +import { IFilesConfigurationService, AutoSaveMode } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; +import { ResourceMap } from 'vs/base/common/map'; +import { SymbolsQuickAccessProvider } from 'vs/workbench/contrib/search/browser/symbolsQuickAccess'; + +interface IAnythingQuickPickItem extends IPickerQuickAccessItem { + resource: URI | undefined; +} + +export class AnythingQuickAccessProvider extends PickerQuickAccessProvider { + + static PREFIX = ''; + + private static readonly MAX_RESULTS = 512; + + private static readonly TYPING_SEARCH_DELAY = 200; // this delay accommodates for the user typing a word and then stops typing to start searching + + private readonly pickState = new class { + scorerCache: ScorerCache = Object.create(null); + fileQueryCache: FileQueryCacheState | undefined; + + constructor(private readonly provider: AnythingQuickAccessProvider) { } + + reset(): void { + this.fileQueryCache = this.provider.createFileQueryCache(); + this.scorerCache = Object.create(null); + } + }(this); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ISearchService private readonly searchService: ISearchService, + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, + @IRemotePathService private readonly remotePathService: IRemotePathService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IFileService private readonly fileService: IFileService, + @ILabelService private readonly labelService: ILabelService, + @IModelService private readonly modelService: IModelService, + @IModeService private readonly modeService: IModeService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorService private readonly editorService: IEditorService, + @IHistoryService private readonly historyService: IHistoryService, + @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService + ) { + super(AnythingQuickAccessProvider.PREFIX, { canAcceptInBackground: true }); + } + + private get configuration() { + const editorConfig = this.configurationService.getValue().workbench.editor; + const searchConfig = this.configurationService.getValue(); + + return { + openEditorPinned: !editorConfig.enablePreviewFromQuickOpen, + openSideBySideDirection: editorConfig.openSideBySideDirection, + includeSymbols: searchConfig.search.quickOpen.includeSymbols, + includeHistory: searchConfig.search.quickOpen.includeHistory, + shortAutoSaveDelay: this.filesConfigurationService.getAutoSaveMode() === AutoSaveMode.AFTER_SHORT_DELAY + }; + } + + provide(picker: IQuickPick, token: CancellationToken): IDisposable { + + // Reset the pick state for this run + this.pickState.reset(); + + // Start picker + return super.provide(picker, token); + } + + protected getPicks(filter: string, disposables: DisposableStore, token: CancellationToken): FastAndSlowPicksType { + + // Find a suitable range from the pattern looking for ":", "#" or "," + let range: IRange | undefined = undefined; + const filterWithRange = extractRangeFromFilter(filter); + if (filterWithRange) { + filter = filterWithRange.filter; + range = filterWithRange.range; + } + + const query = prepareQuery(filter); + + const historyEditorPicks = this.getEditorHistoryPicks(query, range); + + return { + + // Fast picks: editor history + picks: historyEditorPicks.length > 0 ? + [ + { type: 'separator', label: localize('recentlyOpenedSeparator', "recently opened") }, + ...historyEditorPicks + ] : [], + + // Slow picks: files and symbols + additionalPicks: (async (): Promise> => { + + // Exclude any result that is already present in editor history + const additionalPicksExcludes = new ResourceMap(); + for (const historyEditorPick of historyEditorPicks) { + if (historyEditorPick.resource) { + additionalPicksExcludes.set(historyEditorPick.resource, true); + } + } + + const additionalPicks = await this.getAdditionalPicks(query, range, additionalPicksExcludes, token); + if (token.isCancellationRequested) { + return []; + } + + return additionalPicks.length > 0 ? [ + { type: 'separator', label: this.configuration.includeSymbols ? localize('fileAndSymbolResultsSeparator', "file and symbol results") : localize('fileResultsSeparator', "file results") }, + ...additionalPicks + ] : []; + })() + }; + } + + private async getAdditionalPicks(query: IPreparedQuery, range: IRange | undefined, excludes: ResourceMap, token: CancellationToken): Promise> { + + // Resolve file and symbol picks (if enabled) + const [filePicks, symbolPicks] = await Promise.all([ + this.getFilePicks(query, range, excludes, token), + this.getSymbolPicks(query, range, token) + ]); + + if (token.isCancellationRequested) { + return []; + } + + // Sort top 512 items by score + const sortedAnythingPicks = top( + [...filePicks, ...symbolPicks], + (anyPickA, anyPickB) => compareItemsByScore(anyPickA, anyPickB, query, true, quickPickItemScorerAccessor, this.pickState.scorerCache), + AnythingQuickAccessProvider.MAX_RESULTS + ); + + // Adjust highlights + for (const anythingPick of sortedAnythingPicks) { + if (anythingPick.highlights) { + continue; // preserve any highlights we got already (e.g. symbols) + } + + const { labelMatch, descriptionMatch } = scoreItem(anythingPick, query, true, quickPickItemScorerAccessor, this.pickState.scorerCache); + + anythingPick.highlights = { + label: labelMatch, + description: descriptionMatch + }; + } + + return sortedAnythingPicks; + } + + + //#region Editor History + + private readonly labelOnlyEditorHistoryPickAccessor = new QuickPickItemScorerAccessor({ skipDescription: true }); + + protected getEditorHistoryPicks(query: IPreparedQuery, range: IRange | undefined): Array { + + // Just return all history entries if not searching + if (!query.value) { + return this.historyService.getHistory().map(editor => this.createAnythingPick(editor, range)); + } + + if (!this.configuration.includeHistory) { + return []; // disabled when searching + } + + // Only match on label of the editor unless the search includes path separators + const editorHistoryScorerAccessor = query.containsPathSeparator ? quickPickItemScorerAccessor : this.labelOnlyEditorHistoryPickAccessor; + + // Otherwise filter and sort by query + const editorHistoryPicks: Array = []; + for (const editor of this.historyService.getHistory()) { + const resource = editor.resource; + if (!resource || (!this.fileService.canHandleResource(resource) && resource.scheme !== Schemas.untitled)) { + continue; // exclude editors without file resource if we are searching by pattern + } + + const editorHistoryPick = this.createAnythingPick(editor, range); + + const { score, labelMatch, descriptionMatch } = scoreItem(editorHistoryPick, query, false, editorHistoryScorerAccessor, this.pickState.scorerCache); + if (!score) { + continue; // exclude editors not matching query + } + + editorHistoryPick.highlights = { + label: labelMatch, + description: descriptionMatch + }; + + editorHistoryPicks.push(editorHistoryPick); + } + + return editorHistoryPicks.sort((editorA, editorB) => compareItemsByScore(editorA, editorB, query, false, editorHistoryScorerAccessor, this.pickState.scorerCache, () => -1)); + } + + //#endregion + + + //#region File Search + + private fileQueryDelayer = this._register(new ThrottledDelayer(AnythingQuickAccessProvider.TYPING_SEARCH_DELAY)); + + private fileQueryBuilder = this.instantiationService.createInstance(QueryBuilder); + + private createFileQueryCache(): FileQueryCacheState { + return new FileQueryCacheState( + cacheKey => this.fileQueryBuilder.file(this.contextService.getWorkspace().folders, this.getFileQueryOptions({ cacheKey })), + query => this.searchService.fileSearch(query), + cacheKey => this.searchService.clearCache(cacheKey), + this.pickState.fileQueryCache + ).load(); + } + + protected async getFilePicks(query: IPreparedQuery, range: IRange | undefined, excludes: ResourceMap, token: CancellationToken): Promise> { + if (!query.value) { + return []; + } + + // Absolute path result + const absolutePathResult = await this.getAbsolutePathFileResult(query, token); + if (token.isCancellationRequested) { + return []; + } + + // Use absolute path result as only results if present + let fileMatches: Array>; + if (absolutePathResult) { + fileMatches = [{ resource: absolutePathResult }]; + } + + // Otherwise run the file search (with a delayer if cache is not ready yet) + else { + if (this.pickState.fileQueryCache?.isLoaded) { + fileMatches = await this.doFileSearch(query, token); + } else { + fileMatches = await this.fileQueryDelayer.trigger(async () => { + if (token.isCancellationRequested) { + return []; + } + + return this.doFileSearch(query, token); + }); + } + } + + if (token.isCancellationRequested) { + return []; + } + + // Filter excludes & convert to picks + return fileMatches + .filter(fileMatch => !excludes.has(fileMatch.resource)) + .map(fileMatch => this.createAnythingPick(fileMatch.resource, range)); + } + + private async doFileSearch(query: IPreparedQuery, token: CancellationToken): Promise { + const { results } = await this.searchService.fileSearch( + this.fileQueryBuilder.file( + this.contextService.getWorkspace().folders, + this.getFileQueryOptions({ + filePattern: query.original, + cacheKey: this.pickState.fileQueryCache?.cacheKey, + maxResults: AnythingQuickAccessProvider.MAX_RESULTS + }) + ), token); + + return results; + } + + private getFileQueryOptions(input: { filePattern?: string, cacheKey?: string, maxResults?: number }): IFileQueryBuilderOptions { + const fileQueryOptions: IFileQueryBuilderOptions = { + _reason: 'openFileHandler', // used for telemetry - do not change + extraFileResources: this.instantiationService.invokeFunction(getOutOfWorkspaceEditorResources), + filePattern: input.filePattern || '', + cacheKey: input.cacheKey, + maxResults: input.maxResults || 0, + sortByScore: true + }; + + return fileQueryOptions; + } + + private async getAbsolutePathFileResult(query: IPreparedQuery, token: CancellationToken): Promise { + const detildifiedQuery = untildify(query.original, (await this.remotePathService.userHome).path); + if (token.isCancellationRequested) { + return undefined; // {{SQL CARBON EDIT}} strict-null + } + + const isAbsolutePathQuery = (await this.remotePathService.path).isAbsolute(detildifiedQuery); + if (token.isCancellationRequested) { + return undefined; // {{SQL CARBON EDIT}} strict-null + } + + if (isAbsolutePathQuery) { + const resource = toLocalResource( + await this.remotePathService.fileURI(detildifiedQuery), + this.environmentService.configuration.remoteAuthority + ); + + if (token.isCancellationRequested) { + return undefined; // {{SQL CARBON EDIT}} strict-null + } + + try { + return (await this.fileService.resolve(resource)).isDirectory ? undefined : resource; + } catch (error) { + // ignore + } + } + + return undefined; // {{SQL CARBON EDIT}} strict-null + } + + //#endregion + + + //#region Symbols (if enabled) + + private symbolsQuickAccess = this._register(this.instantiationService.createInstance(SymbolsQuickAccessProvider)); + + protected async getSymbolPicks(query: IPreparedQuery, range: IRange | undefined, token: CancellationToken): Promise> { + if ( + !query.value || // we need a value for search for + !this.configuration.includeSymbols || // we need to enable symbols in search + range // a range is an indicator for just searching for files + ) { + return []; + } + + // Delegate to the existing symbols quick access + // but skip local results and also do not sort + return this.symbolsQuickAccess.getSymbolPicks(query.value, { skipLocal: true, skipSorting: true, delay: AnythingQuickAccessProvider.TYPING_SEARCH_DELAY }, token); + } + + //#endregion + + + //#region Helpers + + private createAnythingPick(resourceOrEditor: URI | IEditorInput | IResourceEditorInput, range: IRange | undefined): IAnythingQuickPickItem { + const isEditorHistoryEntry = !URI.isUri(resourceOrEditor); + + let resource: URI | undefined; + let label: string; + let description: string | undefined = undefined; + let isDirty: boolean | undefined = undefined; + + if (resourceOrEditor instanceof EditorInput) { + resource = resourceOrEditor.resource; + label = resourceOrEditor.getName(); + description = resourceOrEditor.getDescription(); + isDirty = resourceOrEditor.isDirty() && !resourceOrEditor.isSaving(); + } else { + resource = URI.isUri(resourceOrEditor) ? resourceOrEditor : (resourceOrEditor as IResourceEditorInput).resource; + label = basenameOrAuthority(resource); + description = this.labelService.getUriLabel(dirname(resource), { relative: true }); + isDirty = this.workingCopyService.isDirty(resource) && !this.configuration.shortAutoSaveDelay; + } + + return { + resource, + label, + ariaLabel: isEditorHistoryEntry ? + localize('historyPickAriaLabel', "{0}, recently opened", label) : + localize('filePickAriaLabel', "{0}, file picker", label), + description, + iconClasses: getIconClasses(this.modelService, this.modeService, resource), + buttons: (() => { + const openSideBySideDirection = this.configuration.openSideBySideDirection; + const buttons: IQuickInputButton[] = []; + + // Open to side / below + buttons.push({ + iconClass: openSideBySideDirection === 'right' ? 'codicon-split-horizontal' : 'codicon-split-vertical', + tooltip: openSideBySideDirection === 'right' ? localize('openToSide', "Open to the Side") : localize('openToBottom', "Open to the Bottom") + }); + + // Remove from History + if (isEditorHistoryEntry) { + buttons.push({ + iconClass: isDirty ? 'dirty-anything codicon-circle-filled' : 'codicon-close', + tooltip: localize('closeEditor', "Remove from Recently Opened"), + alwaysVisible: isDirty + }); + } + + return buttons; + })(), + trigger: async (buttonIndex, keyMods) => { + switch (buttonIndex) { + + // Open to side / below + case 0: + this.openAnything(resourceOrEditor, { keyMods, range, forceOpenSideBySide: true }); + return TriggerAction.CLOSE_PICKER; + + // Remove from History + case 1: + if (!URI.isUri(resourceOrEditor)) { + this.historyService.remove(resourceOrEditor); + + return TriggerAction.REFRESH_PICKER; + } + } + + return TriggerAction.NO_ACTION; + }, + accept: (keyMods, event) => this.openAnything(resourceOrEditor, { keyMods, range, preserveFocus: event.inBackground }) + }; + } + + private async openAnything(resourceOrEditor: URI | IEditorInput | IResourceEditorInput, options: { keyMods?: IKeyMods, preserveFocus?: boolean, range?: IRange, forceOpenSideBySide?: boolean }): Promise { + const editorOptions: ITextEditorOptions = { + preserveFocus: options.preserveFocus, + pinned: options.keyMods?.alt || this.configuration.openEditorPinned, + selection: options.range ? Range.collapseToStart(options.range) : undefined + }; + + const targetGroup = options.keyMods?.ctrlCmd || options.forceOpenSideBySide ? SIDE_GROUP : ACTIVE_GROUP; + + if (resourceOrEditor instanceof EditorInput) { + await this.editorService.openEditor(resourceOrEditor, editorOptions); + } else { + await this.editorService.openEditor({ + resource: URI.isUri(resourceOrEditor) ? resourceOrEditor : resourceOrEditor.resource, + options: editorOptions + }, targetGroup); + } + } + + //#endregion +} diff --git a/src/vs/workbench/contrib/search/browser/media/anythingQuickAccess.css b/src/vs/workbench/contrib/search/browser/media/anythingQuickAccess.css new file mode 100644 index 0000000000..de1cf70a6f --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/media/anythingQuickAccess.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.quick-input-list .quick-input-list-entry.has-actions:hover .quick-input-list-entry-action-bar .action-label.dirty-anything::before { + content: "\ea76"; /* Close icon flips between black dot and "X" for dirty open editors */ +} diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index ef71dbc93c..3d467fd350 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -57,6 +57,7 @@ import { SearchEditor } from 'vs/workbench/contrib/searchEditor/browser/searchEd import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { IQuickAccessRegistry, Extensions as QuickAccessExtensions } from 'vs/platform/quickinput/common/quickAccess'; import { SymbolsQuickAccessProvider } from 'vs/workbench/contrib/search/browser/symbolsQuickAccess'; +import { AnythingQuickAccessProvider } from 'vs/workbench/contrib/search/browser/anythingQuickAccess'; registerSingleton(ISearchWorkbenchService, SearchWorkbenchService, true); registerSingleton(ISearchHistoryService, SearchHistoryService, true); @@ -654,8 +655,17 @@ Registry.as(QuickOpenExtensions.Quickopen).registerQuickOpen ); // Register Quick Access Handler +const quickAccessRegistry = Registry.as(QuickAccessExtensions.Quickaccess); -Registry.as(QuickAccessExtensions.Quickaccess).registerQuickAccessProvider({ +quickAccessRegistry.registerQuickAccessProvider({ + ctor: AnythingQuickAccessProvider, + prefix: AnythingQuickAccessProvider.PREFIX, + placeholder: nls.localize('anythingQuickAccessPlaceholder', "Type '?' to get help on the actions you can take from here"), + contextKey: 'inFilesPicker', + helpEntries: [{ description: nls.localize('anythingQuickAccess', "Go to File"), needsEditor: false }] +}); + +quickAccessRegistry.registerQuickAccessProvider({ ctor: SymbolsQuickAccessProvider, prefix: SymbolsQuickAccessProvider.PREFIX, placeholder: nls.localize('symbolsQuickAccessPlaceholder', "Type the name of a symbol to open."), diff --git a/src/vs/workbench/contrib/search/browser/symbolsQuickAccess.ts b/src/vs/workbench/contrib/search/browser/symbolsQuickAccess.ts index 04588fee20..a4a1619e06 100644 --- a/src/vs/workbench/contrib/search/browser/symbolsQuickAccess.ts +++ b/src/vs/workbench/contrib/search/browser/symbolsQuickAccess.ts @@ -19,23 +19,25 @@ import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from 'vs/workbench/services/ import { Range } from 'vs/editor/common/core/range'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IWorkbenchEditorConfiguration } from 'vs/workbench/common/editor'; -import { IKeyMods, IQuickPick } from 'vs/platform/quickinput/common/quickInput'; +import { IKeyMods } from 'vs/platform/quickinput/common/quickInput'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { createResourceExcludeMatcher } from 'vs/workbench/services/search/common/search'; import { ResourceMap } from 'vs/base/common/map'; +import { URI } from 'vs/base/common/uri'; -interface ISymbolsQuickPickItem extends IPickerQuickAccessItem { - score: FuzzyScore; +interface ISymbolQuickPickItem extends IPickerQuickAccessItem { + resource: URI | undefined; + score: FuzzyScore | undefined; symbol: IWorkspaceSymbol; } -export class SymbolsQuickAccessProvider extends PickerQuickAccessProvider { +export class SymbolsQuickAccessProvider extends PickerQuickAccessProvider { static PREFIX = '#'; private static readonly TYPING_SEARCH_DELAY = 200; // this delay accommodates for the user typing a word and then stops typing to start searching - private delayer = new ThrottledDelayer(SymbolsQuickAccessProvider.TYPING_SEARCH_DELAY); + private delayer = this._register(new ThrottledDelayer(SymbolsQuickAccessProvider.TYPING_SEARCH_DELAY)); private readonly resourceExcludeMatcher = this._register(createResourceExcludeMatcher(this.instantiationService, this.configurationService)); @@ -46,13 +48,7 @@ export class SymbolsQuickAccessProvider extends PickerQuickAccessProvider): void { - - // Allow to open symbols in background without closing picker - picker.canAcceptInBackground = true; + super(SymbolsQuickAccessProvider.PREFIX, { canAcceptInBackground: true }); } private get configuration() { @@ -64,23 +60,27 @@ export class SymbolsQuickAccessProvider extends PickerQuickAccessProvider> { + protected getPicks(filter: string, disposables: DisposableStore, token: CancellationToken): Promise> { + return this.getSymbolPicks(filter, undefined, token); + } + + async getSymbolPicks(filter: string, options: { skipLocal: boolean, skipSorting: boolean, delay: number } | undefined, token: CancellationToken): Promise> { return this.delayer.trigger(async () => { if (token.isCancellationRequested) { return []; } - return this.doGetSymbolPicks(filter, token); - }); + return this.doGetSymbolPicks(filter, options, token); + }, options?.delay); } - private async doGetSymbolPicks(filter: string, token: CancellationToken): Promise> { + private async doGetSymbolPicks(filter: string, options: { skipLocal: boolean, skipSorting: boolean } | undefined, token: CancellationToken): Promise> { const workspaceSymbols = await getWorkspaceSymbols(filter, token); if (token.isCancellationRequested) { return []; } - const symbolPicks: Array = []; + const symbolPicks: Array = []; // Normalize filter const [symbolFilter, containerFilter] = stripWildcards(filter).split(' ') as [string, string | undefined]; @@ -92,6 +92,9 @@ export class SymbolsQuickAccessProvider extends PickerQuickAccessProvider(); for (const [provider, symbols] of workspaceSymbols) { for (const symbol of symbols) { + if (options?.skipLocal && !!symbol.containerName) { + continue; // ignore local symbols if we are told so + } // Score by symbol label const symbolLabel = symbol.name; @@ -141,6 +144,7 @@ export class SymbolsQuickAccessProvider extends PickerQuickAccessProvider this.openSymbol(provider, symbol, token, keyMods, { preserveFocus: event.inBackground }), trigger: (buttonIndex, keyMods) => { - this.openSymbol(provider, symbol, token, keyMods, { forceOpenSideBySide: true }); + this.openSymbol(provider, symbol, token, { keyMods, forceOpenSideBySide: true }); return TriggerAction.CLOSE_PICKER; - } + }, + accept: async (keyMods, event) => this.openSymbol(provider, symbol, token, { keyMods, preserveFocus: event.inBackground }), }); } } - // Sort picks - symbolPicks.sort((symbolA, symbolB) => this.compareSymbols(symbolA, symbolB)); + // Sort picks (unless disabled) + if (!options?.skipSorting) { + symbolPicks.sort((symbolA, symbolB) => this.compareSymbols(symbolA, symbolB)); + } return symbolPicks; } - private async openSymbol(provider: IWorkspaceSymbolProvider, symbol: IWorkspaceSymbol, token: CancellationToken, keyMods: IKeyMods, options: { forceOpenSideBySide?: boolean, preserveFocus?: boolean }): Promise { + private async openSymbol(provider: IWorkspaceSymbolProvider, symbol: IWorkspaceSymbol, token: CancellationToken, options: { keyMods: IKeyMods, forceOpenSideBySide?: boolean, preserveFocus?: boolean }): Promise { // Resolve actual symbol to open for providers that can resolve let symbolToOpen = symbol; @@ -195,14 +201,14 @@ export class SymbolsQuickAccessProvider extends PickerQuickAccessProvider | undefined; + + constructor( + private cacheQuery: (cacheKey: string) => IFileQuery, + private loadFn: (query: IFileQuery) => Promise, + private disposeFn: (cacheKey: string) => Promise, + private previousCacheState: FileQueryCacheState | undefined + ) { + if (this.previousCacheState) { + const current = assign({}, this.query, { cacheKey: null }); + const previous = assign({}, this.previousCacheState.query, { cacheKey: null }); + if (!equals(current, previous)) { + this.previousCacheState.dispose(); + this.previousCacheState = undefined; + } + } + } + + load(): FileQueryCacheState { + if (this.isUpdating) { + return this; + } + + this.loadingPhase = LoadingPhase.Loading; + + this.loadPromise = (async () => { + try { + await this.loadFn(this.query); + + this.loadingPhase = LoadingPhase.Loaded; + + if (this.previousCacheState) { + this.previousCacheState.dispose(); + this.previousCacheState = undefined; + } + } catch (error) { + this.loadingPhase = LoadingPhase.Errored; + + throw error; + } + })(); + + return this; + } + + dispose(): void { + if (this.loadPromise) { + (async () => { + try { + await this.loadPromise; + } catch (error) { + // ignore + } + + this.loadingPhase = LoadingPhase.Disposed; + this.disposeFn(this._cacheKey); + })(); + } else { + this.loadingPhase = LoadingPhase.Disposed; + } + + if (this.previousCacheState) { + this.previousCacheState.dispose(); + this.previousCacheState = undefined; + } + } +} diff --git a/src/vs/workbench/contrib/search/common/search.ts b/src/vs/workbench/contrib/search/common/search.ts index 18dd9ec2cc..a9db9a2b3b 100644 --- a/src/vs/workbench/contrib/search/common/search.ts +++ b/src/vs/workbench/contrib/search/common/search.ts @@ -14,6 +14,8 @@ import { IEditorService } from 'vs/workbench/services/editor/common/editorServic import { CancellationToken } from 'vs/base/common/cancellation'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IFileService } from 'vs/platform/files/common/files'; +import { IRange } from 'vs/editor/common/core/range'; +import { isNumber } from 'vs/base/common/types'; export interface IWorkspaceSymbol { name: string; @@ -74,6 +76,7 @@ export function getWorkspaceSymbols(query: string, token: CancellationToken = Ca export interface IWorkbenchSearchConfigurationProperties extends ISearchConfigurationProperties { quickOpen: { includeSymbols: boolean; + includeHistory: boolean; }; } @@ -95,3 +98,62 @@ export function getOutOfWorkspaceEditorResources(accessor: ServicesAccessor): UR return resources as URI[]; } + +// Supports patterns of <#|:|(><#|:|,> +const LINE_COLON_PATTERN = /\s?[#:\(](\d*)([#:,](\d*))?\)?\s*$/; + +export function extractRangeFromFilter(filter: string): { filter: string, range: IRange } | undefined { + if (!filter) { + return undefined; + } + + let range: IRange | undefined = undefined; + + // Find Line/Column number from search value using RegExp + const patternMatch = LINE_COLON_PATTERN.exec(filter); + if (patternMatch && patternMatch.length > 1) { + const startLineNumber = parseInt(patternMatch[1], 10); + + // Line Number + if (isNumber(startLineNumber)) { + range = { + startLineNumber: startLineNumber, + startColumn: 1, + endLineNumber: startLineNumber, + endColumn: 1 + }; + + // Column Number + if (patternMatch.length > 3) { + const startColumn = parseInt(patternMatch[3], 10); + if (isNumber(startColumn)) { + range = { + startLineNumber: range.startLineNumber, + startColumn: startColumn, + endLineNumber: range.endLineNumber, + endColumn: startColumn + }; + } + } + } + + // User has typed "something:" or "something#" without a line number, in this case treat as start of file + else if (patternMatch[1] === '') { + range = { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1 + }; + } + } + + if (patternMatch && range) { + return { + filter: filter.substr(0, patternMatch.index), // clear range suffix from search value + range: range + }; + } + + return undefined; +} diff --git a/src/vs/workbench/contrib/search/test/browser/openFileHandler.test.ts b/src/vs/workbench/contrib/search/test/common/cacheState.test.ts similarity index 96% rename from src/vs/workbench/contrib/search/test/browser/openFileHandler.test.ts rename to src/vs/workbench/contrib/search/test/common/cacheState.test.ts index 32648ebd30..5554aca1ac 100644 --- a/src/vs/workbench/contrib/search/test/browser/openFileHandler.test.ts +++ b/src/vs/workbench/contrib/search/test/common/cacheState.test.ts @@ -6,11 +6,11 @@ import * as assert from 'assert'; import * as errors from 'vs/base/common/errors'; import * as objects from 'vs/base/common/objects'; -import { CacheState } from 'vs/workbench/contrib/search/browser/openFileHandler'; import { DeferredPromise } from 'vs/base/test/common/utils'; import { QueryType, IFileQuery } from 'vs/workbench/services/search/common/search'; +import { FileQueryCacheState } from 'vs/workbench/contrib/search/common/cacheState'; -suite('CacheState', () => { +suite('FileQueryCacheState', () => { test('reuse old cacheKey until new cache is loaded', async function () { @@ -162,8 +162,8 @@ suite('CacheState', () => { assert.strictEqual(third.cacheKey, thirdKey); // recover with next successful load }); - function createCacheState(cache: MockCache, previous?: CacheState): CacheState { - return new CacheState( + function createCacheState(cache: MockCache, previous?: FileQueryCacheState): FileQueryCacheState { + return new FileQueryCacheState( cacheKey => cache.query(cacheKey), query => cache.load(query), cacheKey => cache.dispose(cacheKey), diff --git a/src/vs/workbench/contrib/search/test/common/extractRange.test.ts b/src/vs/workbench/contrib/search/test/common/extractRange.test.ts new file mode 100644 index 0000000000..bffcda4562 --- /dev/null +++ b/src/vs/workbench/contrib/search/test/common/extractRange.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { extractRangeFromFilter } from 'vs/workbench/contrib/search/common/search'; + +suite('extractRangeFromFilter', () => { + + test('basics', async function () { + assert.ok(!extractRangeFromFilter('')); + assert.ok(!extractRangeFromFilter('/some/path')); + assert.ok(!extractRangeFromFilter('/some/path/file.txt')); + + for (const lineSep of [':', '#', '(']) { + for (const colSep of [':', '#', ',']) { + const base = '/some/path/file.txt'; + + let res = extractRangeFromFilter(`${base}${lineSep}20`); + assert.equal(res?.filter, base); + assert.equal(res?.range.startLineNumber, 20); + assert.equal(res?.range.startColumn, 1); + + res = extractRangeFromFilter(`${base}${lineSep}20${colSep}`); + assert.equal(res?.filter, base); + assert.equal(res?.range.startLineNumber, 20); + assert.equal(res?.range.startColumn, 1); + + res = extractRangeFromFilter(`${base}${lineSep}20${colSep}3`); + assert.equal(res?.filter, base); + assert.equal(res?.range.startLineNumber, 20); + assert.equal(res?.range.startColumn, 3); + } + } + }); + + test('allow space after path', async function () { + let res = extractRangeFromFilter('/some/path/file.txt (19,20)'); + + assert.equal(res?.filter, '/some/path/file.txt'); + assert.equal(res?.range.startLineNumber, 19); + assert.equal(res?.range.startColumn, 20); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index cbd7188e18..6e79f74ee8 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -40,7 +40,7 @@ import { BrowserFeatures } from 'vs/base/browser/canIUse'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { IQuickAccessRegistry, Extensions as QuickAccessExtensions } from 'vs/platform/quickinput/common/quickAccess'; -import { TerminalQuickAccessProvider } from 'vs/workbench/contrib/terminal/browser/terminaQuickAccess'; +import { TerminalQuickAccessProvider } from 'vs/workbench/contrib/terminal/browser/terminalsQuickAccess'; registerSingleton(ITerminalService, TerminalService, true); @@ -295,7 +295,7 @@ configurationRegistry.registerConfiguration({ default: true }, 'terminal.integrated.allowMnemonics': { - markdownDescription: nls.localize('terminal.integrated.allowMnemonics', "Whether to allow menubar mnemonics (eg. alt+f) to trigger the open the menubar. Note that this will cause all alt keystrokes will skip the shell when true."), + markdownDescription: nls.localize('terminal.integrated.allowMnemonics', "Whether to allow menubar mnemonics (eg. alt+f) to trigger the open the menubar. Note that this will cause all alt keystrokes will skip the shell when true. This does nothing on macOS."), type: 'boolean', default: false }, diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index dca16b626b..4718a94449 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -621,7 +621,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } // Skip processing by xterm.js of keyboard events that match menu bar mnemonics - if (this._configHelper.config.allowMnemonics && event.altKey) { + if (this._configHelper.config.allowMnemonics && !platform.isMacintosh && event.altKey) { return false; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminaQuickAccess.ts b/src/vs/workbench/contrib/terminal/browser/terminalsQuickAccess.ts similarity index 90% rename from src/vs/workbench/contrib/terminal/browser/terminaQuickAccess.ts rename to src/vs/workbench/contrib/terminal/browser/terminalsQuickAccess.ts index f1599f4516..fe54c423e6 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminaQuickAccess.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalsQuickAccess.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; -import { IQuickPickSeparator, IQuickPick } from 'vs/platform/quickinput/common/quickInput'; +import { IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput'; import { IPickerQuickAccessItem, PickerQuickAccessProvider, TriggerAction } from 'vs/platform/quickinput/browser/pickerQuickAccess'; import { matchesFuzzy } from 'vs/base/common/filters'; import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; @@ -19,13 +19,7 @@ export class TerminalQuickAccessProvider extends PickerQuickAccessProvider): void { - - // Allow to open terminals in background without closing picker - picker.canAcceptInBackground = true; + super(TerminalQuickAccessProvider.PREFIX, { canAcceptInBackground: true }); } protected getPicks(filter: string): Array { diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index 6790124e85..7d26e63598 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -25,12 +25,15 @@ import { CommandsRegistry, ICommandService } from 'vs/platform/commands/common/c import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { IFileService } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { CONTEXT_SYNC_STATE, getUserDataSyncStore, ISyncConfiguration, IUserDataAutoSyncService, IUserDataSyncService, IUserDataSyncStore, registerConfiguration, SyncResource, SyncStatus, UserDataSyncError, UserDataSyncErrorCode, USER_DATA_SYNC_SCHEME, IUserDataSyncEnablementService, getSyncSourceFromPreviewResource, CONTEXT_SYNC_ENABLEMENT, PREVIEW_QUERY, resolveSyncResource, toRemoteSyncResource } from 'vs/platform/userDataSync/common/userDataSync'; +import { + CONTEXT_SYNC_STATE, getUserDataSyncStore, ISyncConfiguration, IUserDataAutoSyncService, IUserDataSyncService, IUserDataSyncStore, registerConfiguration, + SyncResource, SyncStatus, UserDataSyncError, UserDataSyncErrorCode, USER_DATA_SYNC_SCHEME, IUserDataSyncEnablementService, CONTEXT_SYNC_ENABLEMENT, + SyncResourceConflicts, Conflict, getSyncResourceFromLocalPreview, getSyncResourceFromRemotePreview +} from 'vs/platform/userDataSync/common/userDataSync'; import { FloatingClickWidget } from 'vs/workbench/browser/parts/editor/editorWidgets'; import { GLOBAL_ACTIVITY_ID } from 'vs/workbench/common/activity'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; @@ -133,10 +136,9 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo @IOutputService private readonly outputService: IOutputService, @IAuthenticationTokenService private readonly authTokenService: IAuthenticationTokenService, @IUserDataAutoSyncService userDataAutoSyncService: IUserDataAutoSyncService, - @ITextModelService textModelResolverService: ITextModelService, + @ITextModelService private readonly textModelResolverService: ITextModelService, @IPreferencesService private readonly preferencesService: IPreferencesService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IFileService private readonly fileService: IFileService, @IProductService private readonly productService: IProductService, @IStorageService private readonly storageService: IStorageService, @IOpenerService private readonly openerService: IOpenerService, @@ -150,10 +152,10 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo if (this.userDataSyncStore) { registerConfiguration(); this.onDidChangeSyncStatus(this.userDataSyncService.status); - this.onDidChangeConflicts(this.userDataSyncService.conflictsSources); + this.onDidChangeConflicts(this.userDataSyncService.conflicts); this.onDidChangeEnablement(this.userDataSyncEnablementService.isEnabled()); this._register(Event.debounce(userDataSyncService.onDidChangeStatus, () => undefined, 500)(() => this.onDidChangeSyncStatus(this.userDataSyncService.status))); - this._register(userDataSyncService.onDidChangeConflicts(() => this.onDidChangeConflicts(this.userDataSyncService.conflictsSources))); + this._register(userDataSyncService.onDidChangeConflicts(() => this.onDidChangeConflicts(this.userDataSyncService.conflicts))); this._register(userDataSyncService.onSyncErrors(errors => this.onSyncErrors(errors))); this._register(this.authTokenService.onTokenFailed(_ => this.onTokenFailed())); this._register(this.userDataSyncEnablementService.onDidChangeEnablement(enabled => this.onDidChangeEnablement(enabled))); @@ -284,44 +286,45 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo } private readonly conflictsDisposables = new Map(); - private onDidChangeConflicts(conflicts: SyncResource[]) { + private onDidChangeConflicts(conflicts: SyncResourceConflicts[]) { this.updateBadge(); if (conflicts.length) { - this.conflictsSources.set(this.userDataSyncService.conflictsSources.join(',')); + const conflictsSources: SyncResource[] = conflicts.map(conflict => conflict.syncResource); + this.conflictsSources.set(conflictsSources.join(',')); // Clear and dispose conflicts those were cleared this.conflictsDisposables.forEach((disposable, conflictsSource) => { - if (this.userDataSyncService.conflictsSources.indexOf(conflictsSource) === -1) { + if (conflictsSources.indexOf(conflictsSource) === -1) { disposable.dispose(); this.conflictsDisposables.delete(conflictsSource); } }); - for (const conflictsSource of this.userDataSyncService.conflictsSources) { - const conflictsEditorInput = this.getConflictsEditorInput(conflictsSource); - if (!conflictsEditorInput && !this.conflictsDisposables.has(conflictsSource)) { - const conflictsArea = getSyncAreaLabel(conflictsSource); + for (const { syncResource, conflicts } of this.userDataSyncService.conflicts) { + const conflictsEditorInput = this.getConflictsEditorInput(syncResource); + if (!conflictsEditorInput && !this.conflictsDisposables.has(syncResource)) { + const conflictsArea = getSyncAreaLabel(syncResource); const handle = this.notificationService.prompt(Severity.Warning, localize('conflicts detected', "Unable to sync due to conflicts in {0}. Please resolve them to continue.", conflictsArea.toLowerCase()), [ { label: localize('accept remote', "Accept Remote"), run: () => { - this.telemetryService.publicLog2<{ source: string, action: string }, SyncConflictsClassification>('sync/handleConflicts', { source: conflictsSource, action: 'acceptRemote' }); - this.acceptRemote(conflictsSource); + this.telemetryService.publicLog2<{ source: string, action: string }, SyncConflictsClassification>('sync/handleConflicts', { source: syncResource, action: 'acceptRemote' }); + this.acceptRemote(syncResource, conflicts); } }, { label: localize('accept local', "Accept Local"), run: () => { - this.telemetryService.publicLog2<{ source: string, action: string }, SyncConflictsClassification>('sync/handleConflicts', { source: conflictsSource, action: 'acceptLocal' }); - this.acceptLocal(conflictsSource); + this.telemetryService.publicLog2<{ source: string, action: string }, SyncConflictsClassification>('sync/handleConflicts', { source: syncResource, action: 'acceptLocal' }); + this.acceptLocal(syncResource, conflicts); } }, { label: localize('show conflicts', "Show Conflicts"), run: () => { - this.telemetryService.publicLog2<{ source: string, action?: string }, SyncConflictsClassification>('sync/showConflicts', { source: conflictsSource }); - this.handleConflicts(conflictsSource); + this.telemetryService.publicLog2<{ source: string, action?: string }, SyncConflictsClassification>('sync/showConflicts', { source: syncResource }); + this.handleConflicts({ syncResource, conflicts }); } } ], @@ -329,18 +332,18 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo sticky: true } ); - this.conflictsDisposables.set(conflictsSource, toDisposable(() => { + this.conflictsDisposables.set(syncResource, toDisposable(() => { // close the conflicts warning notification handle.close(); // close opened conflicts editor previews - const conflictsEditorInput = this.getConflictsEditorInput(conflictsSource); + const conflictsEditorInput = this.getConflictsEditorInput(syncResource); if (conflictsEditorInput) { conflictsEditorInput.dispose(); } - this.conflictsDisposables.delete(conflictsSource); + this.conflictsDisposables.delete(syncResource); })); } } @@ -352,29 +355,24 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo } } - private async acceptRemote(syncResource: SyncResource) { + private async acceptRemote(syncResource: SyncResource, conflicts: Conflict[]) { try { - const contents = await this.userDataSyncService.resolveContent(toRemoteSyncResource(syncResource).with({ query: PREVIEW_QUERY })); - if (contents) { - await this.userDataSyncService.accept(syncResource, contents); + for (const conflict of conflicts) { + const modelRef = await this.textModelResolverService.createModelReference(conflict.remote); + await this.userDataSyncService.acceptConflict(conflict.remote, modelRef.object.textEditorModel.getValue()); + modelRef.dispose(); } } catch (e) { this.notificationService.error(e); } } - private async acceptLocal(syncSource: SyncResource): Promise { + private async acceptLocal(syncResource: SyncResource, conflicts: Conflict[]): Promise { try { - const previewResource = syncSource === SyncResource.Settings - ? this.workbenchEnvironmentService.settingsSyncPreviewResource - : syncSource === SyncResource.Keybindings - ? this.workbenchEnvironmentService.keybindingsSyncPreviewResource - : null; - if (previewResource) { - const fileContent = await this.fileService.readFile(previewResource); - if (fileContent) { - this.userDataSyncService.accept(syncSource, fileContent.value.toString()); - } + for (const conflict of conflicts) { + const modelRef = await this.textModelResolverService.createModelReference(conflict.local); + await this.userDataSyncService.acceptConflict(conflict.local, modelRef.object.textEditorModel.getValue()); + modelRef.dispose(); } } catch (e) { this.notificationService.error(e); @@ -497,8 +495,8 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo if (this.userDataSyncService.status !== SyncStatus.Uninitialized && this.userDataSyncEnablementService.isEnabled() && this.authenticationState.get() === AuthStatus.SignedOut) { badge = new NumberBadge(1, () => localize('sign in to sync', "Sign in to Sync")); - } else if (this.userDataSyncService.conflictsSources.length) { - badge = new NumberBadge(this.userDataSyncService.conflictsSources.length, () => localize('has conflicts', "Sync: Conflicts Detected")); + } else if (this.userDataSyncService.conflicts.length) { + badge = new NumberBadge(this.userDataSyncService.conflicts.length, () => localize('has conflicts', "Sync: Conflicts Detected")); } if (badge) { @@ -729,35 +727,35 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo } } - private getConflictsEditorInput(source: SyncResource): IEditorInput | undefined { - const previewResource = source === SyncResource.Settings ? this.workbenchEnvironmentService.settingsSyncPreviewResource - : source === SyncResource.Keybindings ? this.workbenchEnvironmentService.keybindingsSyncPreviewResource - : null; - return previewResource ? this.editorService.editors.filter(input => input instanceof DiffEditorInput && isEqual(previewResource, input.master.resource))[0] : undefined; + private getConflictsEditorInput(syncResource: SyncResource): IEditorInput | undefined { + return this.editorService.editors.filter(input => input instanceof DiffEditorInput && getSyncResourceFromLocalPreview(input.master.resource!, this.workbenchEnvironmentService) === syncResource)[0]; } private getAllConflictsEditorInputs(): IEditorInput[] { return this.editorService.editors.filter(input => { const resource = input instanceof DiffEditorInput ? input.master.resource : input.resource; - return isEqual(resource, this.workbenchEnvironmentService.settingsSyncPreviewResource) || isEqual(resource, this.workbenchEnvironmentService.keybindingsSyncPreviewResource); + return getSyncResourceFromLocalPreview(resource!, this.workbenchEnvironmentService) !== undefined; }); } - private async handleConflicts(resource: SyncResource): Promise { - let previewResource: URI | undefined = undefined; - let label: string = ''; - if (resource === SyncResource.Settings) { - previewResource = this.workbenchEnvironmentService.settingsSyncPreviewResource; - label = localize('settings conflicts preview', "Settings Conflicts (Remote ↔ Local)"); - } else if (resource === SyncResource.Keybindings) { - previewResource = this.workbenchEnvironmentService.keybindingsSyncPreviewResource; - label = localize('keybindings conflicts preview', "Keybindings Conflicts (Remote ↔ Local)"); + private async handleSyncResourceConflicts(resource: SyncResource): Promise { + const syncResourceCoflicts = this.userDataSyncService.conflicts.filter(({ syncResource }) => syncResource === resource)[0]; + if (syncResourceCoflicts) { + this.handleConflicts(syncResourceCoflicts); } - if (previewResource) { - const remoteContentResource = toRemoteSyncResource(resource).with({ query: PREVIEW_QUERY }); + } + + private async handleConflicts({ syncResource, conflicts }: SyncResourceConflicts): Promise { + for (const conflict of conflicts) { + let label: string | undefined = undefined; + if (syncResource === SyncResource.Settings) { + label = localize('settings conflicts preview', "Settings Conflicts (Remote ↔ Local)"); + } else if (syncResource === SyncResource.Keybindings) { + label = localize('keybindings conflicts preview', "Keybindings Conflicts (Remote ↔ Local)"); + } await this.editorService.openEditor({ - leftResource: remoteContentResource, - rightResource: previewResource, + leftResource: conflict.remote, + rightResource: conflict.local, label, options: { preserveFocus: false, @@ -846,7 +844,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo private registerShowSettingsConflictsAction(): void { const resolveSettingsConflictsWhenContext = ContextKeyExpr.regex(CONTEXT_CONFLICTS_SOURCES.keys()[0], /.*settings.*/i); - CommandsRegistry.registerCommand(resolveSettingsConflictsCommand.id, () => this.handleConflicts(SyncResource.Settings)); + CommandsRegistry.registerCommand(resolveSettingsConflictsCommand.id, () => this.handleSyncResourceConflicts(SyncResource.Settings)); MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { group: '5_sync', command: { @@ -873,7 +871,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo private registerShowKeybindingsConflictsAction(): void { const resolveKeybindingsConflictsWhenContext = ContextKeyExpr.regex(CONTEXT_CONFLICTS_SOURCES.keys()[0], /.*keybindings.*/i); - CommandsRegistry.registerCommand(resolveKeybindingsConflictsCommand.id, () => this.handleConflicts(SyncResource.Keybindings)); + CommandsRegistry.registerCommand(resolveKeybindingsConflictsCommand.id, () => this.handleSyncResourceConflicts(SyncResource.Keybindings)); MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { group: '5_sync', command: { @@ -931,9 +929,9 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo const quickPick = quickInputService.createQuickPick(); disposables.add(quickPick); const items: Array = []; - if (that.userDataSyncService.conflictsSources.length) { - for (const source of that.userDataSyncService.conflictsSources) { - switch (source) { + if (that.userDataSyncService.conflicts.length) { + for (const { syncResource } of that.userDataSyncService.conflicts) { + switch (syncResource) { case SyncResource.Settings: items.push({ id: resolveSettingsConflictsCommand.id, label: resolveSettingsConflictsCommand.title }); break; @@ -1109,11 +1107,11 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio return false; // we need a model } - if (getSyncSourceFromPreviewResource(model.uri, this.environmentService) !== undefined) { + if (getSyncResourceFromLocalPreview(model.uri, this.environmentService) !== undefined) { return true; } - if (resolveSyncResource(model.uri) !== null && model.uri.query === PREVIEW_QUERY) { + if (getSyncResourceFromRemotePreview(model.uri, this.environmentService) !== undefined) { return this.configurationService.getValue('diffEditor.renderSideBySide'); } @@ -1123,14 +1121,14 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio private createAcceptChangesWidgetRenderer(): void { if (!this.acceptChangesButton) { - const isRemote = resolveSyncResource(this.editor.getModel()!.uri) !== null; + const isRemote = getSyncResourceFromRemotePreview(this.editor.getModel()!.uri, this.environmentService) !== undefined; const acceptRemoteLabel = localize('accept remote', "Accept Remote"); const acceptLocalLabel = localize('accept local', "Accept Local"); this.acceptChangesButton = this.instantiationService.createInstance(FloatingClickWidget, this.editor, isRemote ? acceptRemoteLabel : acceptLocalLabel, null); this._register(this.acceptChangesButton.onClick(async () => { const model = this.editor.getModel(); if (model) { - const conflictsSource = (getSyncSourceFromPreviewResource(model.uri, this.environmentService) || resolveSyncResource(model.uri)!.resource)!; + const conflictsSource = (getSyncResourceFromLocalPreview(model.uri, this.environmentService) || getSyncResourceFromRemotePreview(model.uri, this.environmentService))!; this.telemetryService.publicLog2<{ source: string, action: string }, SyncConflictsClassification>('sync/handleConflicts', { source: conflictsSource, action: isRemote ? 'acceptRemote' : 'acceptLocal' }); const syncAreaLabel = getSyncAreaLabel(conflictsSource); const result = await this.dialogService.confirm({ @@ -1145,10 +1143,11 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio }); if (result.confirmed) { try { - await this.userDataSyncService.accept(conflictsSource, model.getValue()); + await this.userDataSyncService.acceptConflict(model.uri, model.getValue()); } catch (e) { if (e instanceof UserDataSyncError && e.code === UserDataSyncErrorCode.LocalPreconditionFailed) { - if (this.userDataSyncService.conflictsSources.indexOf(conflictsSource) !== -1) { + const syncResourceCoflicts = this.userDataSyncService.conflicts.filter(({ syncResource }) => syncResource === conflictsSource)[0]; + if (syncResourceCoflicts && syncResourceCoflicts.conflicts.some(conflict => isEqual(conflict.local, model.uri) || isEqual(conflict.remote, model.uri))) { this.notificationService.warn(localize('update conflicts', "Could not resolve conflicts as there is new local version available. Please try again.")); } } else { diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncView.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncView.ts index e6e49d344e..5172161477 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncView.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncView.ts @@ -10,7 +10,7 @@ import { localize } from 'vs/nls'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { TreeViewPane, TreeView } from 'vs/workbench/browser/parts/views/treeView'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; -import { ALL_SYNC_RESOURCES, CONTEXT_SYNC_ENABLEMENT, IUserDataSyncStoreService, toRemoteSyncResource, resolveSyncResource, IUserDataSyncBackupStoreService, IResourceRefHandle, toLocalBackupSyncResource, SyncResource } from 'vs/platform/userDataSync/common/userDataSync'; +import { ALL_SYNC_RESOURCES, CONTEXT_SYNC_ENABLEMENT, IUserDataSyncStoreService, toRemoteBackupSyncResource, resolveBackupSyncResource, IUserDataSyncBackupStoreService, IResourceRefHandle, toLocalBackupSyncResource, SyncResource } from 'vs/platform/userDataSync/common/userDataSync'; import { registerAction2, Action2, MenuId } from 'vs/platform/actions/common/actions'; import { IContextKeyService, RawContextKey, ContextKeyExpr, ContextKeyEqualsExpr } from 'vs/platform/contextkey/common/contextkey'; import { URI } from 'vs/base/common/uri'; @@ -61,7 +61,7 @@ export class UserDataSyncViewContribution implements IWorkbenchContribution { disposable.dispose(); treeView.dataProvider = this.instantiationService.createInstance(UserDataSyncHistoryViewDataProvider, id, (resource: SyncResource) => remote ? this.userDataSyncStoreService.getAllRefs(resource) : this.userDataSyncBackupStoreService.getAllRefs(resource), - (resource: SyncResource, ref: string) => remote ? toRemoteSyncResource(resource, ref) : toLocalBackupSyncResource(resource, ref)); + (resource: SyncResource, ref: string) => remote ? toRemoteBackupSyncResource(resource, ref) : toLocalBackupSyncResource(resource, ref)); } }); const viewsRegistry = Registry.as(Extensions.ViewsRegistry); @@ -111,7 +111,7 @@ export class UserDataSyncViewContribution implements IWorkbenchContribution { async run(accessor: ServicesAccessor, handle: TreeViewItemHandleArg): Promise { const editorService = accessor.get(IEditorService); let resource = URI.parse(handle.$treeItemHandle); - const result = resolveSyncResource(resource); + const result = resolveBackupSyncResource(resource); if (result) { resource = resource.with({ fragment: result.resource }); await editorService.openEditor({ resource }); @@ -149,7 +149,7 @@ export class UserDataSyncViewContribution implements IWorkbenchContribution { const editorService = accessor.get(IEditorService); const environmentService = accessor.get(IEnvironmentService); const resource = URI.parse(handle.$treeItemHandle); - const result = resolveSyncResource(resource); + const result = resolveBackupSyncResource(resource); if (result) { const leftResource: URI = resource.with({ fragment: result.resource }); const rightResource: URI = result.resource === 'settings' ? environmentService.settingsResource : environmentService.keybindingsResource; diff --git a/src/vs/workbench/services/editor/browser/codeEditorService.ts b/src/vs/workbench/services/editor/browser/codeEditorService.ts index 911ff821cc..d79b3f87a6 100644 --- a/src/vs/workbench/services/editor/browser/codeEditorService.ts +++ b/src/vs/workbench/services/editor/browser/codeEditorService.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor, isCodeEditor, isDiffEditor, isCompositeEditor } from 'vs/editor/browser/editorBrowser'; import { CodeEditorServiceImpl } from 'vs/editor/browser/services/codeEditorServiceImpl'; import { ScrollType } from 'vs/editor/common/editorCommon'; import { IResourceEditorInput } from 'vs/platform/editor/common/editor'; @@ -32,10 +32,15 @@ export class CodeEditorService extends CodeEditorServiceImpl { return activeTextEditorControl.getModifiedEditor(); } + const activeControl = this.editorService.activeEditorPane?.getControl(); + if (isCompositeEditor(activeControl) && isCodeEditor(activeControl.activeCodeEditor)) { + return activeControl.activeCodeEditor; + } + return null; } - openCodeEditor(input: IResourceEditorInput, source: ICodeEditor | null, sideBySide?: boolean): Promise { + async openCodeEditor(input: IResourceEditorInput, source: ICodeEditor | null, sideBySide?: boolean): Promise { // Special case: If the active editor is a diff editor and the request to open originates and // targets the modified side of it, we just apply the request there to prevent opening the modified @@ -55,7 +60,7 @@ export class CodeEditorService extends CodeEditorServiceImpl { const textOptions = TextEditorOptions.create(input.options); textOptions.apply(targetEditor, ScrollType.Smooth); - return Promise.resolve(targetEditor); + return targetEditor; } // Open using our normal editor service diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 742a44fa32..ceaef8e43b 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -21,7 +21,7 @@ import { IResourceEditorInputType, SIDE_GROUP, IResourceEditorReplacement, IOpen import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { Disposable, IDisposable, dispose, toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; import { coalesce, distinct } from 'vs/base/common/arrays'; -import { isCodeEditor, isDiffEditor, ICodeEditor, IDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { isCodeEditor, isDiffEditor, ICodeEditor, IDiffEditor, isCompositeEditor } from 'vs/editor/browser/editorBrowser'; import { IEditorGroupView, IEditorOpeningEvent, EditorServiceImpl } from 'vs/workbench/browser/parts/editor/editor'; import { ILabelService } from 'vs/platform/label/common/label'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; @@ -400,6 +400,9 @@ export class EditorService extends Disposable implements EditorServiceImpl { if (isCodeEditor(activeControl) || isDiffEditor(activeControl)) { return activeControl; } + if (isCompositeEditor(activeControl) && isCodeEditor(activeControl.activeCodeEditor)) { + return activeControl.activeCodeEditor; + } } return undefined; diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index ad0acd352c..2af3d16ff9 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -105,12 +105,6 @@ export class BrowserWorkbenchEnvironmentService implements IWorkbenchEnvironment @memoize get userDataSyncHome(): URI { return joinPath(this.userRoamingDataHome, 'sync'); } - @memoize - get settingsSyncPreviewResource(): URI { return joinPath(this.userDataSyncHome, 'settings.json'); } - - @memoize - get keybindingsSyncPreviewResource(): URI { return joinPath(this.userDataSyncHome, 'keybindings.json'); } - @memoize get userDataSyncLogResource(): URI { return joinPath(this.options.logsPath, 'userDataSync.log'); } diff --git a/src/vs/workbench/services/progress/browser/progressService.ts b/src/vs/workbench/services/progress/browser/progressService.ts index 867940b13a..e59e663687 100644 --- a/src/vs/workbench/services/progress/browser/progressService.ts +++ b/src/vs/workbench/services/progress/browser/progressService.ts @@ -251,7 +251,7 @@ export class ProgressService extends Disposable implements IProgressService { return toDisposable(() => promiseResolve()); }; - const createNotification = (message: string, increment?: number): INotificationHandle => { + const createNotification = (message: string, silent: boolean, increment?: number): INotificationHandle => { const notificationDisposables = new DisposableStore(); const primaryActions = options.primaryActions ? Array.from(options.primaryActions) : []; @@ -294,7 +294,8 @@ export class ProgressService extends Disposable implements IProgressService { message, source: options.source, actions: { primary: primaryActions, secondary: secondaryActions }, - progress: typeof increment === 'number' && increment >= 0 ? { total: 100, worked: increment } : { infinite: true } + progress: typeof increment === 'number' && increment >= 0 ? { total: 100, worked: increment } : { infinite: true }, + silent }); // Switch to window based progress once the notification @@ -302,8 +303,7 @@ export class ProgressService extends Disposable implements IProgressService { // Remove that window based progress once the notification // shows again. let windowProgressDisposable: IDisposable | undefined = undefined; - notificationDisposables.add(notification.onDidChangeVisibility(visible => { - + const onVisibilityChange = (visible: boolean) => { // Clear any previous running window progress dispose(windowProgressDisposable); @@ -311,7 +311,11 @@ export class ProgressService extends Disposable implements IProgressService { if (!visible && !progressStateModel.done) { windowProgressDisposable = createWindowProgress(); } - })); + }; + notificationDisposables.add(notification.onDidChangeVisibility(onVisibilityChange)); + if (silent) { + onVisibilityChange(false); + } // Clear upon dispose Event.once(notification.onDidClose)(() => notificationDisposables.dispose()); @@ -346,10 +350,10 @@ export class ProgressService extends Disposable implements IProgressService { // create notification now or after a delay if (typeof options.delay === 'number' && options.delay > 0) { if (typeof notificationTimeout !== 'number') { - notificationTimeout = setTimeout(() => notificationHandle = createNotification(titleAndMessage!, step?.increment), options.delay); + notificationTimeout = setTimeout(() => notificationHandle = createNotification(titleAndMessage!, !!options.silent, step?.increment), options.delay); } } else { - notificationHandle = createNotification(titleAndMessage, step?.increment); + notificationHandle = createNotification(titleAndMessage, !!options.silent, step?.increment); } } diff --git a/src/vs/workbench/services/userDataSync/electron-browser/userDataSyncService.ts b/src/vs/workbench/services/userDataSync/electron-browser/userDataSyncService.ts index 86a2ae2e9b..a446767c1f 100644 --- a/src/vs/workbench/services/userDataSync/electron-browser/userDataSyncService.ts +++ b/src/vs/workbench/services/userDataSync/electron-browser/userDataSyncService.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { SyncStatus, SyncResource, IUserDataSyncService, UserDataSyncError } from 'vs/platform/userDataSync/common/userDataSync'; +import { SyncStatus, SyncResource, IUserDataSyncService, UserDataSyncError, SyncResourceConflicts } from 'vs/platform/userDataSync/common/userDataSync'; import { ISharedProcessService } from 'vs/platform/ipc/electron-browser/sharedProcessService'; import { Disposable } from 'vs/base/common/lifecycle'; import { Emitter, Event } from 'vs/base/common/event'; @@ -25,10 +25,10 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ get onDidChangeLocal(): Event { return this.channel.listen('onDidChangeLocal'); } - private _conflictsSources: SyncResource[] = []; - get conflictsSources(): SyncResource[] { return this._conflictsSources; } - private _onDidChangeConflicts: Emitter = this._register(new Emitter()); - readonly onDidChangeConflicts: Event = this._onDidChangeConflicts.event; + private _conflicts: SyncResourceConflicts[] = []; + get conflicts(): SyncResourceConflicts[] { return this._conflicts; } + private _onDidChangeConflicts: Emitter = this._register(new Emitter()); + readonly onDidChangeConflicts: Event = this._onDidChangeConflicts.event; private _lastSyncTime: number | undefined = undefined; get lastSyncTime(): number | undefined { return this._lastSyncTime; } @@ -52,7 +52,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ return userDataSyncChannel.listen(event, arg); } }; - this.channel.call<[SyncStatus, SyncResource[], number | undefined]>('_getInitialData').then(([status, conflicts, lastSyncTime]) => { + this.channel.call<[SyncStatus, SyncResourceConflicts[], number | undefined]>('_getInitialData').then(([status, conflicts, lastSyncTime]) => { this.updateStatus(status); this.updateConflicts(conflicts); if (lastSyncTime) { @@ -61,7 +61,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ this._register(this.channel.listen('onDidChangeStatus')(status => this.updateStatus(status))); this._register(this.channel.listen('onDidChangeLastSyncTime')(lastSyncTime => this.updateLastSyncTime(lastSyncTime))); }); - this._register(this.channel.listen('onDidChangeConflicts')(conflicts => this.updateConflicts(conflicts))); + this._register(this.channel.listen('onDidChangeConflicts')(conflicts => this.updateConflicts(conflicts))); this._register(this.channel.listen<[SyncResource, Error][]>('onSyncErrors')(errors => this._onSyncErrors.fire(errors.map(([source, error]) => ([source, UserDataSyncError.toUserDataSyncError(error)]))))); } @@ -73,8 +73,8 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ return this.channel.call('sync'); } - accept(source: SyncResource, content: string): Promise { - return this.channel.call('accept', [source, content]); + acceptConflict(conflict: URI, content: string): Promise { + return this.channel.call('acceptConflict', [conflict, content]); } reset(): Promise { @@ -102,8 +102,14 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ this._onDidChangeStatus.fire(status); } - private async updateConflicts(conflicts: SyncResource[]): Promise { - this._conflictsSources = conflicts; + private async updateConflicts(conflicts: SyncResourceConflicts[]): Promise { + // Revive URIs + this._conflicts = conflicts.map(c => + ({ + syncResource: c.syncResource, + conflicts: c.conflicts.map(({ local, remote }) => + ({ local: URI.revive(local), remote: URI.revive(remote) })) + })); this._onDidChangeConflicts.fire(conflicts); } diff --git a/src/vs/workbench/services/views/browser/viewDescriptorService.ts b/src/vs/workbench/services/views/browser/viewDescriptorService.ts index b398303073..57d145ee4c 100644 --- a/src/vs/workbench/services/views/browser/viewDescriptorService.ts +++ b/src/vs/workbench/services/views/browser/viewDescriptorService.ts @@ -445,11 +445,6 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } moveViewToLocation(view: IViewDescriptor, location: ViewContainerLocation): void { - const previousContainer = this.getViewContainer(view.id); - if (previousContainer && this.getViewContainerLocation(previousContainer) === location) { - return; - } - let container = this.getDefaultContainer(view.id)!; if (this.getViewContainerLocation(container) !== location) { container = this.registerViewContainerForSingleView(view, location); diff --git a/src/vs/workbench/test/browser/quickAccess.test.ts b/src/vs/workbench/test/browser/quickAccess.test.ts index 21e1b5daf0..ee398f136d 100644 --- a/src/vs/workbench/test/browser/quickAccess.test.ts +++ b/src/vs/workbench/test/browser/quickAccess.test.ts @@ -12,6 +12,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; import { DisposableStore, toDisposable, IDisposable } from 'vs/base/common/lifecycle'; import { timeout } from 'vs/base/common/async'; +import { PickerQuickAccessProvider, FastAndSlowPicksType } from 'vs/platform/quickinput/browser/pickerQuickAccess'; suite('QuickAccess', () => { @@ -192,4 +193,122 @@ suite('QuickAccess', () => { restore(); }); + + let fastProviderCalled = false; + let slowProviderCalled = false; + let fastAndSlowProviderCalled = false; + + let slowProviderCanceled = false; + let fastAndSlowProviderCanceled = false; + + class FastTestQuickPickProvider extends PickerQuickAccessProvider { + + constructor() { + super('fast'); + } + + protected getPicks(filter: string, disposables: DisposableStore, token: CancellationToken): Array { + fastProviderCalled = true; + + return [{ label: 'Fast Pick' }]; + } + } + + class SlowTestQuickPickProvider extends PickerQuickAccessProvider { + + constructor() { + super('slow'); + } + + protected async getPicks(filter: string, disposables: DisposableStore, token: CancellationToken): Promise> { + slowProviderCalled = true; + + await timeout(1); + + if (token.isCancellationRequested) { + slowProviderCanceled = true; + } + + return [{ label: 'Slow Pick' }]; + } + } + + class FastAndSlowTestQuickPickProvider extends PickerQuickAccessProvider { + + constructor() { + super('bothFastAndSlow'); + } + + protected getPicks(filter: string, disposables: DisposableStore, token: CancellationToken): FastAndSlowPicksType { + fastAndSlowProviderCalled = true; + + return { + picks: [{ label: 'Fast Pick' }], + additionalPicks: (async () => { + await timeout(1); + + if (token.isCancellationRequested) { + fastAndSlowProviderCanceled = true; + } + + return [{ label: 'Slow Pick' }]; + })() + }; + } + } + + const fastProviderDescriptor = { ctor: FastTestQuickPickProvider, prefix: 'fast', helpEntries: [] }; + const slowProviderDescriptor = { ctor: SlowTestQuickPickProvider, prefix: 'slow', helpEntries: [] }; + const fastAndSlowProviderDescriptor = { ctor: FastAndSlowTestQuickPickProvider, prefix: 'bothFastAndSlow', helpEntries: [] }; + + test('quick pick access', async () => { + const registry = (Registry.as(Extensions.Quickaccess)); + const restore = (registry as QuickAccessRegistry).clear(); + + const disposables = new DisposableStore(); + + disposables.add(registry.registerQuickAccessProvider(fastProviderDescriptor)); + disposables.add(registry.registerQuickAccessProvider(slowProviderDescriptor)); + disposables.add(registry.registerQuickAccessProvider(fastAndSlowProviderDescriptor)); + + accessor.quickInputService.quickAccess.show('fast'); + assert.equal(fastProviderCalled, true); + assert.equal(slowProviderCalled, false); + assert.equal(fastAndSlowProviderCalled, false); + fastProviderCalled = false; + + accessor.quickInputService.quickAccess.show('slow'); + await timeout(2); + + assert.equal(fastProviderCalled, false); + assert.equal(slowProviderCalled, true); + assert.equal(slowProviderCanceled, false); + assert.equal(fastAndSlowProviderCalled, false); + slowProviderCalled = false; + + accessor.quickInputService.quickAccess.show('bothFastAndSlow'); + await timeout(2); + + assert.equal(fastProviderCalled, false); + assert.equal(slowProviderCalled, false); + assert.equal(fastAndSlowProviderCalled, true); + assert.equal(fastAndSlowProviderCanceled, false); + fastAndSlowProviderCalled = false; + + accessor.quickInputService.quickAccess.show('slow'); + accessor.quickInputService.quickAccess.show('bothFastAndSlow'); + accessor.quickInputService.quickAccess.show('fast'); + + assert.equal(fastProviderCalled, true); + assert.equal(slowProviderCalled, true); + assert.equal(fastAndSlowProviderCalled, true); + + await timeout(2); + assert.equal(slowProviderCanceled, true); + assert.equal(fastAndSlowProviderCanceled, true); + + disposables.dispose(); + + restore(); + }); }); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 54f36387e5..27628bc5f3 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -245,6 +245,9 @@ import 'vs/workbench/contrib/preferences/browser/preferences.contribution'; import 'vs/workbench/contrib/preferences/browser/keybindingsEditorContribution'; import 'vs/workbench/contrib/preferences/browser/preferencesSearch'; +// Notebook +import 'vs/workbench/contrib/notebook/browser/notebook.contribution'; + // Logs import 'vs/workbench/contrib/logs/common/logs.contribution';