diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/scm/scm.ts b/src/vs/workbench/contrib/notebook/browser/contrib/scm/scm.ts new file mode 100644 index 000000000000..7166b1c27486 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/contrib/scm/scm.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { INotebookEditorContribution, INotebookEditor, INotebookDeltaDecoration } from '../../notebookBrowser'; +import { registerNotebookContribution } from '../../notebookEditorExtensions'; +import { ISCMService } from 'vs/workbench/contrib/scm/common/scm'; +import { createProviderComparer } from 'vs/workbench/contrib/scm/browser/dirtydiffDecorator'; +import { first } from 'vs/base/common/async'; +import { INotebookService } from '../../../common/notebookService'; +import { NotebookCellTextModel } from '../../../common/model/notebookCellTextModel'; +import { diff } from '../../../common/notebookCommon'; + +export class SCMController extends Disposable implements INotebookEditorContribution { + static id: string = 'workbench.notebook.findController'; + private _lastDecorationId: string[] = []; + private _localDisposable = new DisposableStore(); + + + constructor( + private readonly _notebookEditor: INotebookEditor, + @ISCMService private readonly _scmService: ISCMService, + @INotebookService private readonly _notebookService: INotebookService + + ) { + super(); + + this._register(this._notebookEditor.onDidChangeModel(() => { + this._localDisposable.clear(); + this.update(); + + if (this._notebookEditor.textModel) { + this._localDisposable.add(this._notebookEditor.textModel.onDidChangeContent(() => { + this.update(); + })); + + this._localDisposable.add(this._notebookEditor.textModel.onDidChangeCells(() => { + this.update(); + })); + } + })); + + this.update(); + } + + async update() { + const modifiedDocument = this._notebookEditor.textModel; + if (!modifiedDocument) { + return; + } + + const uri = modifiedDocument.uri; + const providers = this._scmService.repositories.map(r => r.provider); + const rootedProviders = providers.filter(p => !!p.rootUri); + + rootedProviders.sort(createProviderComparer(uri)); + + const result = await first(rootedProviders.map(p => () => p.getOriginalResource(uri))); + + if (!result) { + this._clear(); + return; + } + + const originalDocument = await this._notebookService.resolveNotebook(modifiedDocument.viewType, result, false); + + if (!originalDocument) { + this._clear(); + return; + } + + // naive diff, runCode50 + // diff: 3.947998046875ms + // diff: 2.615966796875ms + + console.time('diff'); + + const cellDiffs = diff(originalDocument.cells, modifiedDocument.cells, a => { + for (let i = 0; i < originalDocument.cells.length; i++) { + const modifiedCell = originalDocument.cells[i]; + + if (modifiedCell.getValue() === a.getValue()) { + return true; + } + } + + return false; + }, (a, b) => { + return a.getValue() === b.getValue(); + }); + + console.timeEnd('diff'); + + const decorations: INotebookDeltaDecoration[] = []; + + cellDiffs.forEach(diff => { + if (diff.deleteCount === 0) { + // doesn't exist in original + // insert + decorations.push(...diff.toInsert.map(cell => ({ + handle: cell.handle, + options: { gutterClassName: 'nb-gutter-cell-inserted' } + }))); + } else { + if (diff.toInsert.length === 0) { + // diff.deleteCount + // removed from original + } else { + // modification + decorations.push(...diff.toInsert.map(cell => ({ + handle: cell.handle, + options: { gutterClassName: 'nb-gutter-cell-changed' } + }))); + } + } + }); + + + + this._lastDecorationId = this._notebookEditor.deltaCellDecorations(this._lastDecorationId, decorations); + } + + private _clear() { + this._lastDecorationId = this._notebookEditor.deltaCellDecorations(this._lastDecorationId, []); + } +} + +registerNotebookContribution(SCMController.id, SCMController); diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebook.css b/src/vs/workbench/contrib/notebook/browser/media/notebook.css index ee6e7a0db93b..61c9a0d427de 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebook.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebook.css @@ -11,6 +11,48 @@ position: relative; } +.monaco-workbench .notebookOverlay .notebook-gutter { + position: absolute; + width: 6px; + top: 0px; + height: 100%; +} + +.monaco-workbench .notebookOverlay .notebook-gutter .monaco-list-row .cell { + visibility: hidden; + top: -8px; +} + +.monaco-workbench .notebookOverlay .notebook-gutter .monaco-list-row .cell.nb-gutter-cell-changed { + position: relative; + visibility: visible; + width: 2px; + transition: width 80ms linear, left 80ms linear; + background-color: #66afe0; +} + +.monaco-workbench .notebookOverlay .notebook-gutter .monaco-list-row:hover .cell.nb-gutter-cell-changed { + left: 0px; + width: 6px; +} + +.monaco-workbench .notebookOverlay .notebook-gutter .monaco-list-row .cell.nb-gutter-cell-inserted { + position: relative; + visibility: visible; + width: 2px; + transition: width 80ms linear, left 80ms linear; + background-color: #81b88b; +} + +.monaco-workbench .notebookOverlay .notebook-gutter .monaco-list-row:hover .cell.nb-gutter-cell-inserted { + left: 0px; + width: 6px; +} + +.monaco-workbench .notebookOverlay .notebook-gutter .monaco-scrollable-element > .scrollbar{ + visibility: hidden; +} + .monaco-workbench .cell.markdown { user-select: text; -webkit-user-select: text; @@ -55,6 +97,12 @@ width: 100%; } +.monaco-workbench .notebookOverlay > .cell-list-container > .notebook-gutter > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row { + cursor: default; + overflow: visible !important; + width: 100%; +} + .monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.cell-drag-image { position: absolute; top: -500px; diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index b73d6b5c81af..813fd052083c 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -51,6 +51,7 @@ import 'vs/workbench/contrib/notebook/browser/contrib/format/formatting'; import 'vs/workbench/contrib/notebook/browser/contrib/toc/tocProvider'; import 'vs/workbench/contrib/notebook/browser/contrib/marker/markerProvider'; import 'vs/workbench/contrib/notebook/browser/contrib/status/editorStatus'; +import 'vs/workbench/contrib/notebook/browser/contrib/scm/scm'; // Output renderers registration diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts index 0ddd8114cdaf..9c34ea886b93 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -154,6 +154,7 @@ export interface INotebookEditorContribution { export interface INotebookCellDecorationOptions { className?: string; + gutterClassName?: string; outputClassName?: string; } @@ -375,6 +376,8 @@ export interface INotebookEditor extends IEditor { setCellSelection(cell: ICellViewModel, selection: Range): void; + deltaCellDecorations(oldDecorations: string[], newDecorations: INotebookDeltaDecoration[]): string[]; + /** * Change the decorations on cells. * The notebook is virtualized and this method should be called to create/delete editor decorations safely. diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 4cf404ef0e5d..8890f7571f06 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -325,6 +325,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this._list = this.instantiationService.createInstance( NotebookCellList, 'NotebookCellList', + this._overlayContainer, this._body, this.instantiationService.createInstance(NotebookCellListDelegate), renderers, @@ -1640,7 +1641,8 @@ export const cellSymbolHighlight = registerColor('notebook.symbolHighlightBackgr }, nls.localize('notebook.symbolHighlightBackground', "Background color of highlighted cell")); registerThemingParticipant((theme, collector) => { - collector.addRule(`.notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element { + collector.addRule(`.notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element, + .notebookOverlay > .cell-list-container > .notebook-gutter > .monaco-list > .monaco-scrollable-element { padding-top: ${SCROLLABLE_ELEMENT_PADDING_TOP}px; box-sizing: border-box; }`); diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts index 249623eca748..f2b0b7bac480 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. 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 { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { IListRenderer, IListVirtualDelegate, ListError } from 'vs/base/browser/ui/list/list'; -import { IListStyles, IStyleController } from 'vs/base/browser/ui/list/listWidget'; +import { IListStyles, IStyleController, IListOptions } from 'vs/base/browser/ui/list/listWidget'; import { Emitter, Event } from 'vs/base/common/event'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { isMacintosh } from 'vs/base/common/platform'; @@ -24,6 +25,9 @@ import { CellViewModel, NotebookViewModel } from 'vs/workbench/contrib/notebook/ import { diff, IProcessedOutput, NOTEBOOK_EDITOR_CURSOR_BOUNDARY, CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { clamp } from 'vs/base/common/numbers'; import { SCROLLABLE_ELEMENT_PADDING_TOP } from 'vs/workbench/contrib/notebook/browser/constants'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { editorBackground, foreground, focusBorder } from 'vs/platform/theme/common/colorRegistry'; +import { NotebookGutter, NotebookGutterDelegate, GutterRenderer } from 'vs/workbench/contrib/notebook/browser/view/notebookGutter'; export interface IFocusNextPreviousDelegate { onFocusNext(applyFocusNext: () => void): void; @@ -62,8 +66,11 @@ export class NotebookCellList extends WorkbenchList implements ID private readonly _focusNextPreviousDelegate: IFocusNextPreviousDelegate; + private _cellListGutter: WorkbenchList; + constructor( private listUser: string, + parentContainer: HTMLElement, container: HTMLElement, delegate: IListVirtualDelegate, renderers: IListRenderer[], @@ -72,7 +79,8 @@ export class NotebookCellList extends WorkbenchList implements ID @IListService listService: IListService, @IThemeService themeService: IThemeService, @IConfigurationService configurationService: IConfigurationService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IInstantiationService instantiationService: IInstantiationService ) { super(listUser, container, delegate, renderers, options, contextKeyService, listService, themeService, configurationService, keybindingService); NOTEBOOK_CELL_LIST_FOCUSED.bindTo(this.contextKeyService).set(true); @@ -150,6 +158,81 @@ export class NotebookCellList extends WorkbenchList implements ID focus.focusMode = CellFocusMode.Editor; } })); + + const gutterContainer = DOM.append(container, DOM.$('.notebook-gutter')); + + this._cellListGutter = instantiationService.createInstance( + NotebookGutter, + 'NotebookGutter', + gutterContainer, + instantiationService.createInstance(NotebookGutterDelegate), + [instantiationService.createInstance(GutterRenderer, (index, size) => { + DOM.scheduleAtNextAnimationFrame(() => { + this._cellListGutter?.updateElementHeight(index, size); + }); + })], + this.contextKeyService, + { + setRowLineHeight: false, + setRowHeight: false, + supportDynamicHeights: true, + horizontalScrolling: false, + keyboardSupport: false, + mouseSupport: false, + multipleSelectionSupport: false, + enableKeyboardNavigation: false, + additionalScrollHeight: 0, + transformOptimization: false, + styleController: (_suffix: string) => { return this; }, + 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, + }, + accessibilityProvider: { + getAriaLabel() { return null; }, + getWidgetAriaLabel() { + return nls.localize('notebookTreeAriaLabel', "Notebook"); + } + } + }, + ); + + let scrolling = false; + + this._localDisposableStore.add(this.onWillScroll(() => { + scrolling = true; + })); + + this._localDisposableStore.add(this.onDidScroll(() => { + if (this._cellListGutter.scrollTop !== this.scrollTop) { + this._cellListGutter.scrollTop = this.scrollTop; + } + + scrolling = false; + })); + + this._localDisposableStore.add(this._cellListGutter.onDidScroll(() => { + if (scrolling) { + return; + } + + if (this._cellListGutter.scrollTop !== this.scrollTop) { + this.scrollTop = this._cellListGutter.scrollTop; + } + })); } elementAt(position: number): ICellViewModel | undefined { @@ -375,6 +458,8 @@ export class NotebookCellList extends WorkbenchList implements ID super.splice(start, deleteCount, elements); + this._cellListGutter.splice(start, deleteCount, elements); + const selectionsLeft = []; this._viewModel!.selectionHandles.forEach(handle => { if (this._viewModel!.hasCell(handle)) { @@ -563,6 +648,7 @@ export class NotebookCellList extends WorkbenchList implements ID const focused = this.getFocus(); this.view.updateElementHeight(index, size, focused.length ? focused[0] : null); + this._cellListGutter.updateElementHeight(index, size); } // override @@ -801,6 +887,19 @@ export class NotebookCellList extends WorkbenchList implements ID } } + updateOptions(options: IListOptions) { + super.updateOptions(options); + + if (options.additionalScrollHeight !== undefined) { + this._cellListGutter.updateOptions({ additionalScrollHeight: options.additionalScrollHeight }); + } + } + + layout(height?: number, width?: number): void { + super.layout(height, width); + this._cellListGutter.layout(height, width); + } + style(styles: IListStyles) { const selectorSuffix = this.view.domId; diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookGutter.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookGutter.ts new file mode 100644 index 000000000000..798975ef1e66 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookGutter.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from 'vs/base/browser/dom'; +import { WorkbenchList, IListService, IWorkbenchListOptions } from 'vs/platform/list/browser/listService'; +import { IListVirtualDelegate, IListRenderer } from 'vs/base/browser/ui/list/list'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { CellViewModel } from '../viewModel/notebookViewModel'; +import { DisposableStore } from 'vs/base/common/lifecycle'; + + +interface IGutterRendererTemplate { + container: HTMLElement; + cellContainer: HTMLElement; + elementDisposables: DisposableStore; +} + +export class GutterRenderer implements IListRenderer { + + static TEMPLATE_ID = 'notebook_gutter'; + + templateId = 'notebook_gutter'; + + constructor( + private _elementHeightUpdateDelegate: (index: number, size: number) => void + ) { + } + + renderTemplate(container: HTMLElement): IGutterRendererTemplate { + const cellContainer = DOM.append(container, DOM.$('.cell')); + + return { + container, + cellContainer, + elementDisposables: new DisposableStore() + }; + } + + renderElement(element: CellViewModel, index: number, templateData: IGutterRendererTemplate, height: number | undefined): void { + templateData.cellContainer.style.height = `${element.layoutInfo.totalHeight}px`; + let removedClassNames: string[] = []; + templateData.cellContainer.classList.forEach(className => { + if (/^nb\-.*$/.test(className)) { + removedClassNames.push(className); + } + }); + + removedClassNames.forEach(className => { + templateData.cellContainer.classList.remove(className); + }); + + templateData.elementDisposables.add(element.onDidChangeLayout(() => { + templateData.cellContainer.style.height = `${element.layoutInfo.totalHeight}px`; + this._elementHeightUpdateDelegate(index, element.layoutInfo.totalHeight); + })); + + element.getCellDecorations().forEach(options => { + if (options.gutterClassName) { + DOM.addClass(templateData.cellContainer, options.gutterClassName); + } + }); + + templateData.elementDisposables.add(element.onCellDecorationsChanged((e) => { + e.added.forEach(options => { + if (options.gutterClassName) { + DOM.addClass(templateData.cellContainer, options.gutterClassName); + } + }); + + e.removed.forEach(options => { + if (options.gutterClassName) { + DOM.removeClass(templateData.cellContainer, options.gutterClassName); + } + }); + })); + } + disposeTemplate(templateData: IGutterRendererTemplate): void { + templateData.cellContainer.style.backgroundColor = `#fff`; + + templateData.elementDisposables.clear(); + return; + } + + disposeElement(element: CellViewModel, index: number, templateData: IGutterRendererTemplate): void { + templateData.elementDisposables.clear(); + } +} + +export class NotebookGutterDelegate implements IListVirtualDelegate { + getHeight(element: CellViewModel): number { + return element.layoutInfo.totalHeight; + } + + hasDynamicHeight(element: CellViewModel): boolean { + return false; + } + + getTemplateId(element: CellViewModel): string { + return GutterRenderer.TEMPLATE_ID; + } +} + + +export class NotebookGutter extends WorkbenchList { + constructor( + listUser: string, + container: HTMLElement, + delegate: IListVirtualDelegate, + renderers: IListRenderer[], + contextKeyService: IContextKeyService, + options: IWorkbenchListOptions, + @IListService listService: IListService, + @IThemeService themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IKeybindingService keybindingService: IKeybindingService, + @IInstantiationService instantiationService: IInstantiationService + ) { + super(listUser, container, delegate, renderers, options, contextKeyService, listService, themeService, configurationService, keybindingService); + } +} diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index 6f459d816292..598992107465 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -507,7 +507,7 @@ interface IMutableSplice extends ISplice { deleteCount: number; } -export function diff(before: T[], after: T[], contains: (a: T) => boolean): ISplice[] { +export function diff(before: T[], after: T[], contains: (a: T) => boolean, equal: (a: T, b: T) => boolean = (a: T, b: T) => a === b): ISplice[] { const result: IMutableSplice[] = []; function pushSplice(start: number, deleteCount: number, toInsert: T[]): void { @@ -542,7 +542,7 @@ export function diff(before: T[], after: T[], contains: (a: T) => boolean): I const beforeElement = before[beforeIdx]; const afterElement = after[afterIdx]; - if (beforeElement === afterElement) { + if (equal(beforeElement, afterElement)) { // equal beforeIdx += 1; afterIdx += 1; diff --git a/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts index 5a5affa30d98..6f8b49a3cf5b 100644 --- a/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts +++ b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts @@ -12,7 +12,7 @@ import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; import { Range } from 'vs/editor/common/core/range'; import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; import { EditorModel } from 'vs/workbench/common/editor'; -import { ICellRange, ICellViewModel, INotebookEditor, INotebookEditorContribution, INotebookEditorMouseEvent, NotebookLayoutInfo } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { ICellRange, ICellViewModel, INotebookEditor, INotebookEditorContribution, INotebookEditorMouseEvent, NotebookLayoutInfo, INotebookDeltaDecoration } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { OutputRenderer } from 'vs/workbench/contrib/notebook/browser/view/output/outputRenderer'; import { NotebookEventDispatcher } from 'vs/workbench/contrib/notebook/browser/viewModel/eventDispatcher'; import { CellViewModel, IModelDecorationsChangeAccessor, NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; @@ -258,6 +258,10 @@ export class TestNotebookEditor implements INotebookEditor { throw new Error('Method not implemented.'); } + deltaCellDecorations(oldDecorations: string[], newDecorations: INotebookDeltaDecoration[]): string[] { + throw new Error('Method not implemented.'); + } + deltaCellOutputContainerClassNames(cellId: string, added: string[], removed: string[]): void { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts b/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts index 450448112bb0..77aa3ce26fb7 100644 --- a/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts +++ b/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts @@ -982,7 +982,7 @@ function compareChanges(a: IChange, b: IChange): number { return a.originalEndLineNumber - b.originalEndLineNumber; } -function createProviderComparer(uri: URI): (a: ISCMProvider, b: ISCMProvider) => number { +export function createProviderComparer(uri: URI): (a: ISCMProvider, b: ISCMProvider) => number { return (a, b) => { const aIsParent = isEqualOrParent(uri, a.rootUri!); const bIsParent = isEqualOrParent(uri, b.rootUri!);