notebook diff

This commit is contained in:
rebornix
2020-07-27 10:00:58 -07:00
parent 52dfde3e21
commit 7689b04dbe
10 changed files with 420 additions and 7 deletions
@@ -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<NotebookCellTextModel>(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);
@@ -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;
@@ -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
@@ -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.
@@ -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;
}`);
@@ -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<CellViewModel> implements ID
private readonly _focusNextPreviousDelegate: IFocusNextPreviousDelegate;
private _cellListGutter: WorkbenchList<CellViewModel>;
constructor(
private listUser: string,
parentContainer: HTMLElement,
container: HTMLElement,
delegate: IListVirtualDelegate<CellViewModel>,
renderers: IListRenderer<CellViewModel, BaseCellRenderTemplate>[],
@@ -72,7 +79,8 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> 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<CellViewModel> 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<CellViewModel> 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<CellViewModel> 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<CellViewModel> implements ID
}
}
updateOptions(options: IListOptions<ICellViewModel>) {
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;
@@ -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<CellViewModel, IGutterRendererTemplate> {
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<CellViewModel> {
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<CellViewModel> {
constructor(
listUser: string,
container: HTMLElement,
delegate: IListVirtualDelegate<CellViewModel>,
renderers: IListRenderer<CellViewModel, IGutterRendererTemplate>[],
contextKeyService: IContextKeyService,
options: IWorkbenchListOptions<CellViewModel>,
@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);
}
}
@@ -507,7 +507,7 @@ interface IMutableSplice<T> extends ISplice<T> {
deleteCount: number;
}
export function diff<T>(before: T[], after: T[], contains: (a: T) => boolean): ISplice<T>[] {
export function diff<T>(before: T[], after: T[], contains: (a: T) => boolean, equal: (a: T, b: T) => boolean = (a: T, b: T) => a === b): ISplice<T>[] {
const result: IMutableSplice<T>[] = [];
function pushSplice(start: number, deleteCount: number, toInsert: T[]): void {
@@ -542,7 +542,7 @@ export function diff<T>(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;
@@ -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.');
}
@@ -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!);