From 778a34a9d288da35279e3ffe0c2aca19e1c8d7ed Mon Sep 17 00:00:00 2001 From: Maddy <12754347+MaddyDev@users.noreply.github.com> Date: Thu, 19 Dec 2019 17:21:03 -0800 Subject: [PATCH] Books/search within notebook (#8426) * initial commit * get notebook content * skeleton for find in notebookModel * add search function and keyboard shortcut * add command for hiding find widget * started on search logic * continue search logic * continue search logic * add findcountchange listener * notebook find position * added css class * hide find widget * focus find input * search for multiple occurrences in one line * start notebook find decorations * start adding decorations to notebook model * added editor_model_defaults * added cursor position * merged master and resolved husky erros * initial changes added to Lucyls base implementation * pass NotebbokRange instead of Range to decorations * changes after merging master * temp changes for testing * style updates from vscode merge * implemented the empty methods and added supporting functionality from textModel * just a little error checking * It gets more and more yellow * making highlight work between code cells * highlight only word * remove highlight on close and maintain the position * cleanup of unused references * clean up * find between code cells refactored * highlight markdown line and scroll to it * find index fix * find index fix * code clean up * remove commented code * tslint fix for: Cannot use global 'NodeJS' * linting rule fixes * deltaDecoration base implementation on the base class * moced class defnitions from interface fikle * updated action names * DOM.addClass instead of overwriting * resooved conflicts * moved 'find' code away from notebookmodel to sep class * moved find realted code to seperate folder * created notebookFindModel * clean up * highlight color changes * spacing and typo fixes * highlight correct element for nested elements * do not iterate through paragraphs and li * find accross notebooks * keep track of index * clear decorations on close * floating promises * maintain search context Co-authored-by: Lucy Zhang Co-authored-by: Chris LaFreniere <40371649+chlafreniere@users.noreply.github.com> --- .../browser/cellViews/code.component.ts | 18 +- .../notebook/browser/cellViews/code.css | 8 + .../browser/cellViews/codeCell.component.ts | 22 +- .../browser/cellViews/collapse.component.ts | 4 + .../notebook/browser/cellViews/interfaces.ts | 19 +- .../browser/cellViews/output.component.ts | 8 +- .../cellViews/placeholderCell.component.ts | 4 + .../browser/cellViews/textCell.component.ts | 67 ++ .../notebook/browser/cellViews/textCell.css | 10 +- .../browser/models/modelInterfaces.ts | 68 +- .../notebook/browser/models/notebookInput.ts | 12 +- .../notebook/browser/models/notebookModel.ts | 13 +- .../notebook/browser/notebook.component.ts | 36 +- .../notebook/browser/notebookActions.ts | 34 + .../notebook/browser/notebookEditor.ts | 316 ++++++++- .../notebook/find/notebookFindDecorations.ts | 324 +++++++++ .../notebook/find/notebookFindModel.ts | 669 ++++++++++++++++++ .../notebook/find/notebookFindWidget.ts | 574 +++++++++++++++ .../test/browser/notebookEditorModel.test.ts | 2 + .../workbench/contrib/notebook/test/stubs.ts | 53 +- .../notebook/browser/notebookService.ts | 11 + .../notebook/common/notebookContext.ts | 5 + 22 files changed, 2236 insertions(+), 41 deletions(-) create mode 100644 src/sql/workbench/contrib/notebook/find/notebookFindDecorations.ts create mode 100644 src/sql/workbench/contrib/notebook/find/notebookFindModel.ts create mode 100644 src/sql/workbench/contrib/notebook/find/notebookFindWidget.ts diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/code.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/code.component.ts index 3ef887d6be..d78fb76c18 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/code.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/code.component.ts @@ -6,7 +6,6 @@ import 'vs/css!./code'; import { OnInit, Component, Input, Inject, ElementRef, ViewChild, Output, EventEmitter, OnChanges, SimpleChange, forwardRef, ChangeDetectorRef } from '@angular/core'; -import { AngularDisposable } from 'sql/base/browser/lifecycle'; import { QueryTextEditor } from 'sql/workbench/browser/modelComponents/queryTextEditor'; import { CellToggleMoreActions } from 'sql/workbench/contrib/notebook/browser/cellToggleMoreActions'; import { ICellModel, notebookConstants, CellExecutionState } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; @@ -33,6 +32,7 @@ import { IConnectionManagementService } from 'sql/platform/connection/common/con import { ILogService } from 'vs/platform/log/common/log'; import { CollapseComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/collapse.component'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { CellView } from 'sql/workbench/contrib/notebook/browser/cellViews/interfaces'; import { UntitledTextEditorInput } from 'vs/workbench/common/editor/untitledTextEditorInput'; import { UntitledTextEditorModel } from 'vs/workbench/common/editor/untitledTextEditorModel'; @@ -44,7 +44,7 @@ const DEFAULT_OR_LOCAL_CONTEXT_ID = '-1'; selector: CODE_SELECTOR, templateUrl: decodeURI(require.toUrl('./code.component.html')) }) -export class CodeComponent extends AngularDisposable implements OnInit, OnChanges { +export class CodeComponent extends CellView implements OnInit, OnChanges { @ViewChild('toolbar', { read: ElementRef }) private toolbarElement: ElementRef; @ViewChild('moreactions', { read: ElementRef }) private moreActionsElementRef: ElementRef; @ViewChild('editor', { read: ElementRef }) private codeElement: ElementRef; @@ -141,6 +141,18 @@ export class CodeComponent extends AngularDisposable implements OnInit, OnChange } } + public getEditor(): QueryTextEditor { + return this._editor; + } + + public hasEditor(): boolean { + return true; + } + + public cellGuid(): string { + return this.cellModel.cellGuid; + } + private updateConnectionState(shouldConnect: boolean) { if (this.isSqlCodeCell()) { let cellUri = this.cellModel.cellUri.toString(); @@ -173,7 +185,7 @@ export class CodeComponent extends AngularDisposable implements OnInit, OnChange if (this.destroyed) { return; } - this.createEditor(); + this.createEditor().catch(e => this.logService.error(e)); this._register(DOM.addDisposableListener(window, DOM.EventType.RESIZE, e => { this._layoutEmitter.fire(); })); diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/code.css b/src/sql/workbench/contrib/notebook/browser/cellViews/code.css index bd2c3c6fc9..3a51cbf1f1 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/code.css +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/code.css @@ -66,6 +66,14 @@ code-component .monaco-editor .decorationsOverviewRuler { visibility: hidden; } +.vs code-component .monaco-editor .rangeHighlight { + background-color: rgba(255, 255, 0, 0.2) +} + +.vs-dark code-component .monaco-editor .rangeHighlight { + background-color: rgba(255, 255, 0, 0.2) +} + code-component .carbon-taskbar .codicon { background-size: 20px; width: 40px; diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/codeCell.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/codeCell.component.ts index ed46b33fcd..643000f921 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/codeCell.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/codeCell.component.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { nb } from 'azdata'; -import { OnInit, Component, Input, Inject, forwardRef, ChangeDetectorRef, SimpleChange, OnChanges, HostListener } from '@angular/core'; +import { OnInit, Component, Input, Inject, forwardRef, ChangeDetectorRef, SimpleChange, OnChanges, HostListener, ViewChildren, QueryList } from '@angular/core'; import { CellView } from 'sql/workbench/contrib/notebook/browser/cellViews/interfaces'; import { ICellModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; import { NotebookModel } from 'sql/workbench/contrib/notebook/browser/models/notebookModel'; import { Deferred } from 'sql/base/common/promise'; +import { ICellEditorProvider } from 'sql/workbench/services/notebook/browser/notebookService'; +import { CodeComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/code.component'; +import { OutputComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/output.component'; export const CODE_SELECTOR: string = 'code-cell-component'; @@ -19,6 +22,8 @@ export const CODE_SELECTOR: string = 'code-cell-component'; }) export class CodeCellComponent extends CellView implements OnInit, OnChanges { + @ViewChildren(CodeComponent) private codeCells: QueryList; + @ViewChildren(OutputComponent) private outputCells: QueryList; @Input() cellModel: ICellModel; @Input() set model(value: NotebookModel) { this._model = value; @@ -69,6 +74,17 @@ export class CodeCellComponent extends CellView implements OnInit, OnChanges { } } + public get cellEditors(): ICellEditorProvider[] { + let editors: ICellEditorProvider[] = []; + if (this.codeCells) { + editors.push(...this.codeCells.toArray()); + } + if (this.outputCells) { + editors.push(...this.outputCells.toArray()); + } + return editors; + } + get model(): NotebookModel { return this._model; } @@ -110,4 +126,8 @@ export class CodeCellComponent extends CellView implements OnInit, OnChanges { get isStdInVisible(): boolean { return this.cellModel.stdInVisible; } + + public cellGuid(): string { + return this.cellModel.cellGuid; + } } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/collapse.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/collapse.component.ts index 26fb2a86f5..5725a93774 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/collapse.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/collapse.component.ts @@ -64,6 +64,10 @@ export class CollapseComponent extends CellView implements OnInit, OnChanges { this.cellModel.isCollapsed = !this.cellModel.isCollapsed; } + public cellGuid(): string { + return this.cellModel.cellGuid; + } + public layout() { } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/interfaces.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/interfaces.ts index 9624f0cb40..b626212df4 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/interfaces.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/interfaces.ts @@ -5,11 +5,28 @@ import { OnDestroy } from '@angular/core'; import { AngularDisposable } from 'sql/base/browser/lifecycle'; +import { ICellEditorProvider } from 'sql/workbench/services/notebook/browser/notebookService'; +import { BaseTextEditor } from 'vs/workbench/browser/parts/editor/textEditor'; +import { NotebookRange } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; -export abstract class CellView extends AngularDisposable implements OnDestroy { +export abstract class CellView extends AngularDisposable implements OnDestroy, ICellEditorProvider { constructor() { super(); } public abstract layout(): void; + + public getEditor(): BaseTextEditor | undefined { + return undefined; + } + + public hasEditor(): boolean { + return false; + } + + public abstract cellGuid(): string; + + public deltaDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void { + + } } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/output.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/output.component.ts index c9bb82a79b..a00f578d38 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/output.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/output.component.ts @@ -6,7 +6,6 @@ import 'vs/css!./code'; import 'vs/css!./media/output'; import { OnInit, Component, Input, Inject, ElementRef, ViewChild, SimpleChange, AfterViewInit, forwardRef, ChangeDetectorRef, ComponentRef, ComponentFactoryResolver } from '@angular/core'; -import { AngularDisposable } from 'sql/base/browser/lifecycle'; import { Event } from 'vs/base/common/event'; import { nb } from 'azdata'; import { ICellModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; @@ -21,6 +20,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { localize } from 'vs/nls'; import * as types from 'vs/base/common/types'; import { getErrorMessage } from 'vs/base/common/errors'; +import { CellView } from 'sql/workbench/contrib/notebook/browser/cellViews/interfaces'; export const OUTPUT_SELECTOR: string = 'output-component'; const USER_SELECT_CLASS = 'actionselect'; @@ -31,7 +31,7 @@ const componentRegistry = Registry.as(Extensions.MimeCom selector: OUTPUT_SELECTOR, templateUrl: decodeURI(require.toUrl('./output.component.html')) }) -export class OutputComponent extends AngularDisposable implements OnInit, AfterViewInit { +export class OutputComponent extends CellView implements OnInit, AfterViewInit { @ViewChild('output', { read: ElementRef }) private outputElement: ElementRef; @ViewChild(ComponentHostDirective) componentHost: ComponentHostDirective; @Input() cellOutput: nb.ICellOutput; @@ -184,4 +184,8 @@ export class OutputComponent extends AngularDisposable implements OnInit, AfterV return; } } + + public cellGuid(): string { + return this.cellModel.cellGuid; + } } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/placeholderCell.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/placeholderCell.component.ts index 18c3287d86..dfedb4d63a 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/placeholderCell.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/placeholderCell.component.ts @@ -82,4 +82,8 @@ export class PlaceholderCellComponent extends CellView implements OnInit, OnChan public layout() { } + + public cellGuid(): string { + return this.cellModel.cellGuid; + } } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts index 10fc78a437..0b9654b80b 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts @@ -24,6 +24,7 @@ import { ICellModel } from 'sql/workbench/contrib/notebook/browser/models/modelI import { NotebookModel } from 'sql/workbench/contrib/notebook/browser/models/notebookModel'; import { ISanitizer, defaultSanitizer } from 'sql/workbench/contrib/notebook/browser/outputs/sanitizer'; import { CellToggleMoreActions } from 'sql/workbench/contrib/notebook/browser/cellToggleMoreActions'; +import { NotebookRange } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; export const TEXT_SELECTOR: string = 'text-cell-component'; const USER_SELECT_CLASS = 'actionselect'; @@ -139,6 +140,10 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { } } + public cellGuid(): string { + return this.cellModel.cellGuid; + } + public get isTrusted(): boolean { return this.model.trustedMode; } @@ -243,4 +248,66 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { protected toggleMoreActionsButton(isActiveOrHovered: boolean) { this._cellToggleMoreActions.toggleVisible(!isActiveOrHovered); } + + public deltaDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void { + if (oldDecorationRange) { + this.removeDecoration(oldDecorationRange); + } + + if (newDecorationRange) { + this.addDecoration(newDecorationRange); + } + } + + private addDecoration(range: NotebookRange): void { + if (range && this.output && this.output.nativeElement) { + let children = this.getHtmlElements(); + let ele = children[range.startLineNumber - 1]; + if (ele) { + DOM.addClass(ele, 'rangeHighlight'); + ele.scrollIntoView({ behavior: 'smooth' }); + } + } + } + + private removeDecoration(range: NotebookRange): void { + if (range && this.output && this.output.nativeElement) { + let children = this.getHtmlElements(); + let ele = children[range.startLineNumber - 1]; + if (ele) { + DOM.removeClass(ele, 'rangeHighlight'); + } + } + } + + private getHtmlElements(): any[] { + let hostElem = this.output.nativeElement; + let children = []; + for (let element of hostElem.children) { + if (element.nodeName.toLowerCase() === 'table') { + // add table header and table rows. + children.push(element.children[0]); + for (let trow of element.children[1].children) { + children.push(trow); + } + } else if (element.children.length > 1) { + children = children.concat(this.getChildren(element)); + } else { + children.push(element); + } + } + return children; + } + + private getChildren(parent: any): any[] { + let children: any = []; + if (parent.children.length > 1 && parent.nodeName.toLowerCase() !== 'li' && parent.nodeName.toLowerCase() !== 'p') { + for (let child of parent.children) { + children = children.concat(this.getChildren(child)); + } + } else { + return parent; + } + return children; + } } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.css b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.css index 4e25754887..236b314921 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.css +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.css @@ -17,6 +17,14 @@ text-cell-component .notebook-preview { user-select: text; } +.vs .notebook-preview .rangeHighlight { + background-color: rgba(255, 255, 0, 0.2) +} + +.vs-dark .notebook-preview .rangeHighlight { + background-color: rgba(255, 255, 0, 0.2) +} + text-cell-component table { border-collapse: collapse; border-spacing: 0; @@ -47,4 +55,4 @@ text-cell-component tr { text-cell-component th { font-weight: bold; -} \ No newline at end of file +} diff --git a/src/sql/workbench/contrib/notebook/browser/models/modelInterfaces.ts b/src/sql/workbench/contrib/notebook/browser/models/modelInterfaces.ts index 4dea5cddb8..eb47d4cbb5 100644 --- a/src/sql/workbench/contrib/notebook/browser/models/modelInterfaces.ts +++ b/src/sql/workbench/contrib/notebook/browser/models/modelInterfaces.ts @@ -22,7 +22,9 @@ import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilit import { localize } from 'vs/nls'; import { NotebookModel } from 'sql/workbench/contrib/notebook/browser/models/notebookModel'; import { mssqlProviderName } from 'sql/platform/connection/common/constants'; +import { IModelDecorationsChangeAccessor } from 'vs/editor/common/model'; import { IModelContentChangedEvent } from 'vs/editor/common/model/textModelEvents'; +import { NotebookRange, NotebookFindMatch } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; export interface IClientSessionOptions { notebookUri: URI; @@ -258,7 +260,7 @@ export interface INotebookModel { /** * The active cell for this model. May be undefined */ - readonly activeCell: ICellModel; + activeCell: ICellModel | undefined; /** * Client Session in the notebook, used for sending requests to the notebook service @@ -395,6 +397,8 @@ export interface INotebookModel { getApplicableConnectionProviderIds(kernelName: string): string[]; + updateActiveCell(cell: ICellModel): void; + /** * Get the standardKernelWithProvider by name * @param name The kernel name @@ -408,13 +412,63 @@ export interface INotebookModel { standardKernels: IStandardKernelWithProvider[]; - /** - * Updates the model's view of an active cell to the new active cell - * @param cell New active cell - */ - updateActiveCell(cell: ICellModel); - requestConnection(): Promise; + +} + +export interface INotebookFindModel { + /** Get the find count */ + getFindCount(): number; + + /** Get the find index */ + getFindIndex(): number; + + /** find the next match */ + findNext(): Promise; + + /** find the previous match */ + findPrevious(): Promise; + + /** search the notebook model for the given exp up to maxMatch occurances */ + find(exp: string, maxMatches?: number): Promise; + + /** clear the results of the find */ + clearFind(): void; + + /** return the find results with their ranges */ + findArray: NotebookRange[]; + + /** + * Get the range associated with a decoration. + * @param id The decoration id. + * @return The decoration range or null if the decoration was not found. + */ + getDecorationRange(id: string): NotebookRange | null; + + /** + * Get the range associated with a decoration. + * @param callback that accepts changeAccessor which applies the decorations + * @param ownerId the owner id + * @return The decoration range or null if the decoration was not found. + */ + changeDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => T, ownerId: number): T | null; + + /** + * Get the maximum legal column for line at `lineNumber` + */ + getLineMaxColumn(lineNumber: number): number; + + /** + * Get the number of lines in the model. + */ + getLineCount(): number; + + findMatches: NotebookFindMatch[]; + + findExpression: string; + + /** Emit event when the find count changes */ + onFindCountChange: Event; } export interface NotebookContentChange { diff --git a/src/sql/workbench/contrib/notebook/browser/models/notebookInput.ts b/src/sql/workbench/contrib/notebook/browser/models/notebookInput.ts index 9547d19442..9189c8c20b 100644 --- a/src/sql/workbench/contrib/notebook/browser/models/notebookInput.ts +++ b/src/sql/workbench/contrib/notebook/browser/models/notebookInput.ts @@ -31,6 +31,7 @@ import { UntitledTextEditorInput } from 'vs/workbench/common/editor/untitledText import { ResourceEditorInput } from 'vs/workbench/common/editor/resourceEditorInput'; import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput'; import { BinaryEditorModel } from 'vs/workbench/common/editor/binaryEditorModel'; +import { NotebookFindModel } from 'sql/workbench/contrib/notebook/find/notebookFindModel'; export type ModeViewSaveHandler = (handle: number) => Thenable; @@ -165,7 +166,7 @@ export class NotebookEditorModel extends EditorModel { return this.getNotebookModel() !== undefined; } - private getNotebookModel(): INotebookModel { + public getNotebookModel(): INotebookModel { let editor = this.notebookService.findNotebookEditor(this.notebookUri); if (editor) { return editor.model; @@ -203,6 +204,8 @@ export abstract class NotebookInput extends EditorInput { private _modelResolveInProgress: boolean = false; private _modelResolved: Deferred = new Deferred(); + private _notebookFindModel: NotebookFindModel; + constructor(private _title: string, private resource: URI, private _textInput: TextInput, @@ -233,6 +236,13 @@ export abstract class NotebookInput extends EditorInput { return this.resource; } + public get notebookFindModel(): NotebookFindModel { + if (!this._notebookFindModel) { + this._notebookFindModel = new NotebookFindModel(this._model.getNotebookModel()); + } + return this._notebookFindModel; + } + public get contentManager(): IContentManager { if (!this._contentManager) { this._contentManager = this.instantiationService.createInstance(NotebookEditorContentManager, this); diff --git a/src/sql/workbench/contrib/notebook/browser/models/notebookModel.ts b/src/sql/workbench/contrib/notebook/browser/models/notebookModel.ts index 19032e77fe..5f45020745 100644 --- a/src/sql/workbench/contrib/notebook/browser/models/notebookModel.ts +++ b/src/sql/workbench/contrib/notebook/browser/models/notebookModel.ts @@ -360,7 +360,7 @@ export class NotebookModel extends Disposable implements INotebookModel { return cell; } - public updateActiveCell(cell: ICellModel) { + public updateActiveCell(cell: ICellModel): void { if (this._activeCell) { this._activeCell.active = false; } @@ -426,8 +426,8 @@ export class NotebookModel extends Disposable implements INotebookModel { return this._activeCell; } - public set activeCell(value: ICellModel) { - this._activeCell = value; + public set activeCell(cell: ICellModel) { + this._activeCell = cell; } private notifyError(error: string): void { @@ -597,7 +597,7 @@ export class NotebookModel extends Disposable implements INotebookModel { public changeKernel(displayName: string): void { this._contextsLoadingEmitter.fire(); - this.doChangeKernel(displayName, true); + this.doChangeKernel(displayName, true).catch(e => this.logService.error(e)); } private async doChangeKernel(displayName: string, mustSetProvider: boolean = true, restoreOnFail: boolean = true): Promise { @@ -776,8 +776,8 @@ export class NotebookModel extends Disposable implements INotebookModel { public dispose(): void { super.dispose(); - this.disconnectAttachToConnections(); - this.handleClosed(); + this.disconnectAttachToConnections().catch(e => this.logService.error(e)); + this.handleClosed().catch(e => this.logService.error(e)); } public async handleClosed(): Promise { @@ -998,5 +998,4 @@ export class NotebookModel extends Disposable implements INotebookModel { this._contentChangedEmitter.fire(changeInfo); } - } diff --git a/src/sql/workbench/contrib/notebook/browser/notebook.component.ts b/src/sql/workbench/contrib/notebook/browser/notebook.component.ts index 1562b7a8c1..31cd5f4266 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebook.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebook.component.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { nb } from 'azdata'; -import { OnInit, Component, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild, OnDestroy } from '@angular/core'; +import { OnInit, Component, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild, OnDestroy, ViewChildren, QueryList } from '@angular/core'; import { IColorTheme, IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; import * as themeColors from 'vs/workbench/common/theme'; @@ -25,7 +25,7 @@ import { AngularDisposable } from 'sql/base/browser/lifecycle'; import { CellTypes, CellType } from 'sql/workbench/contrib/notebook/common/models/contracts'; import { ICellModel, IModelFactory, INotebookModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; -import { INotebookService, INotebookParams, INotebookManager, INotebookEditor, DEFAULT_NOTEBOOK_PROVIDER, SQL_NOTEBOOK_PROVIDER, INotebookSection, INavigationProvider } from 'sql/workbench/services/notebook/browser/notebookService'; +import { INotebookService, INotebookParams, INotebookManager, INotebookEditor, DEFAULT_NOTEBOOK_PROVIDER, SQL_NOTEBOOK_PROVIDER, INotebookSection, INavigationProvider, ICellEditorProvider } from 'sql/workbench/services/notebook/browser/notebookService'; import { NotebookModel } from 'sql/workbench/contrib/notebook/browser/models/notebookModel'; import { ModelFactory } from 'sql/workbench/contrib/notebook/browser/models/modelFactory'; import * as notebookUtils from 'sql/workbench/contrib/notebook/browser/models/notebookUtils'; @@ -51,8 +51,11 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { Button } from 'sql/base/browser/ui/button/button'; import { isUndefinedOrNull } from 'vs/base/common/types'; import { IBootstrapParams } from 'sql/workbench/services/bootstrap/common/bootstrapParams'; -import { getErrorMessage } from 'vs/base/common/errors'; +import { getErrorMessage, onUnexpectedError } from 'vs/base/common/errors'; import { find, firstIndex } from 'vs/base/common/arrays'; +import { CodeCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/codeCell.component'; +import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component'; +import { NotebookRange } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; import { ExtensionsViewlet, ExtensionsViewPaneContainer } from 'vs/workbench/contrib/extensions/browser/extensionsViewlet'; @@ -68,6 +71,9 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe @ViewChild('container', { read: ElementRef }) private container: ElementRef; @ViewChild('bookNav', { read: ElementRef }) private bookNav: ElementRef; + @ViewChildren(CodeCellComponent) private codeCells: QueryList; + @ViewChildren(TextCellComponent) private textCells: QueryList; + private _model: NotebookModel; protected _actionBar: Taskbar; protected isLoading: boolean; @@ -116,7 +122,7 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe this.updateTheme(this.themeService.getColorTheme()); this.initActionBar(); this.setScrollPosition(); - this.doLoad(); + this.doLoad().catch(e => onUnexpectedError(e)); this.initNavSection(); } @@ -139,6 +145,28 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe return this._model ? this._model.cells : []; } + public get cellEditors(): ICellEditorProvider[] { + let editors: ICellEditorProvider[] = []; + if (this.codeCells) { + this.codeCells.toArray().forEach(cell => editors.push(...cell.cellEditors)); + } + if (this.textCells) { + editors.push(...this.textCells.toArray()); + } + return editors; + } + + public deltaDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void { + if (newDecorationRange && newDecorationRange.cell && newDecorationRange.cell.cellType === 'markdown') { + let cell = this.cellEditors.filter(c => c.cellGuid() === newDecorationRange.cell.cellGuid)[0]; + cell.deltaDecorations(newDecorationRange, undefined); + } + if (oldDecorationRange && oldDecorationRange.cell && oldDecorationRange.cell.cellType === 'markdown') { + let cell = this.cellEditors.filter(c => c.cellGuid() === oldDecorationRange.cell.cellGuid)[0]; + cell.deltaDecorations(undefined, oldDecorationRange); + } + } + public get addCodeLabel(): string { return localize('addCodeLabel', "Add code"); } diff --git a/src/sql/workbench/contrib/notebook/browser/notebookActions.ts b/src/sql/workbench/contrib/notebook/browser/notebookActions.ts index 789b5e58de..b0141a0cb8 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookActions.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookActions.ts @@ -20,6 +20,8 @@ import { NotebookModel } from 'sql/workbench/contrib/notebook/browser/models/not import { ICommandService } from 'vs/platform/commands/common/commands'; import { CellType } from 'sql/workbench/contrib/notebook/common/models/contracts'; import { getErrorMessage } from 'vs/base/common/errors'; +import { IEditorAction } from 'vs/editor/common/editorCommon'; +import { IFindNotebookController } from 'sql/workbench/contrib/notebook/find/notebookFindWidget'; import { INotebookModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; import { IObjectExplorerService } from 'sql/workbench/services/objectExplorer/browser/objectExplorerService'; import { TreeUpdateUtils } from 'sql/workbench/contrib/objectExplorer/browser/treeUpdateUtils'; @@ -425,3 +427,35 @@ export class NewNotebookAction extends Action { return this.commandService.executeCommand(NewNotebookAction.INTERNAL_NEW_NOTEBOOK_CMD_ID, { connectionProfile: connProfile }); } } + +export class NotebookFindNextAction implements IEditorAction { + public readonly id = 'notebook.findNext'; + public readonly label = localize('notebook.findNext', "Find Next String"); + public readonly alias = ''; + + constructor(private notebook: IFindNotebookController) { } + + async run(): Promise { + await this.notebook.findNext(); + } + + isSupported(): boolean { + return true; + } +} + +export class NotebookFindPreviousAction implements IEditorAction { + public readonly id = 'notebook.findPrevious'; + public readonly label = localize('notebook.findPrevious', "Find Previous String"); + public readonly alias = ''; + + constructor(private notebook: IFindNotebookController) { } + + async run(): Promise { + await this.notebook.findPrevious(); + } + + isSupported(): boolean { + return true; + } +} diff --git a/src/sql/workbench/contrib/notebook/browser/notebookEditor.ts b/src/sql/workbench/contrib/notebook/browser/notebookEditor.ts index d771d0a862..f4f627eecc 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookEditor.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookEditor.ts @@ -14,31 +14,155 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { NotebookInput } from 'sql/workbench/contrib/notebook/browser/models/notebookInput'; import { NotebookModule } from 'sql/workbench/contrib/notebook/browser/notebook.module'; import { NOTEBOOK_SELECTOR } from 'sql/workbench/contrib/notebook/browser/notebook.component'; -import { INotebookParams } from 'sql/workbench/services/notebook/browser/notebookService'; +import { INotebookParams, INotebookService } from 'sql/workbench/services/notebook/browser/notebookService'; import { IStorageService } from 'vs/platform/storage/common/storage'; +import { ACTION_IDS, NOTEBOOK_MAX_MATCHES, IFindNotebookController, FindWidget, IConfigurationChangedEvent } from 'sql/workbench/contrib/notebook/find/notebookFindWidget'; +import { IOverlayWidget } from 'vs/editor/browser/editorBrowser'; +import { FindReplaceState, FindReplaceStateChangedEvent } from 'vs/editor/contrib/find/findState'; +import { IEditorAction } from 'vs/editor/common/editorCommon'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { NotebookFindNextAction, NotebookFindPreviousAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import { INotebookModel, INotebookFindModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; +import { IDisposable, DisposableStore, dispose } from 'vs/base/common/lifecycle'; +import { IModelDecorationsChangeAccessor, IModelDeltaDecoration } from 'vs/editor/common/model'; +import { NotebookFindDecorations, NotebookRange } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; +import { TimeoutTimer } from 'vs/base/common/async'; +import { BaseTextEditor } from 'vs/workbench/browser/parts/editor/textEditor'; +import { onUnexpectedError } from 'vs/base/common/errors'; -export class NotebookEditor extends BaseEditor { +export class NotebookEditor extends BaseEditor implements IFindNotebookController { public static ID: string = 'workbench.editor.notebookEditor'; private _notebookContainer: HTMLElement; + private _currentDimensions: DOM.Dimension; + private _overlay: HTMLElement; + private _findState: FindReplaceState; + private _finder: FindWidget; + private _actionMap: { [x: string]: IEditorAction } = {}; + private _onDidChangeConfiguration = new Emitter(); + public onDidChangeConfiguration: Event = this._onDidChangeConfiguration.event; + private _notebookModel: INotebookModel; + private _findCountChangeListener: IDisposable; + private _currentMatch: NotebookRange; + private _previousMatch: NotebookRange; + private readonly _toDispose = new DisposableStore(); + private readonly _startSearchingTimer: TimeoutTimer; constructor( @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, - @IInstantiationService private instantiationService: IInstantiationService, - @IStorageService storageService: IStorageService + @IInstantiationService private _instantiationService: IInstantiationService, + @IStorageService storageService: IStorageService, + @IContextViewService private _contextViewService: IContextViewService, + @IKeybindingService private _keybindingService: IKeybindingService, + @IContextKeyService private _contextKeyService: IContextKeyService, + @IWorkbenchThemeService private _themeService: IWorkbenchThemeService, + @INotebookService private _notebookService?: INotebookService ) { super(NotebookEditor.ID, telemetryService, themeService, storageService); + this._startSearchingTimer = new TimeoutTimer(); + this._actionMap[ACTION_IDS.FIND_NEXT] = this._instantiationService.createInstance(NotebookFindNextAction, this); + this._actionMap[ACTION_IDS.FIND_PREVIOUS] = this._instantiationService.createInstance(NotebookFindPreviousAction, this); + } + + public dispose(): void { + dispose(this._startSearchingTimer); + this._toDispose.dispose(); } public get notebookInput(): NotebookInput { return this.input as NotebookInput; } + private get _findDecorations(): NotebookFindDecorations { + return this.notebookInput.notebookFindModel.findDecorations; + } + + public getPosition(): NotebookRange { + return this._currentMatch; + } + + public getLastPosition(): NotebookRange { + return this._previousMatch; + } + public getCellEditor(cellGuid: string): BaseTextEditor | undefined { + let editorImpl = this._notebookService.findNotebookEditor(this.notebookInput.notebookUri); + if (editorImpl) { + let cellEditorProvider = editorImpl.cellEditors.filter(c => c.cellGuid() === cellGuid)[0]; + return cellEditorProvider ? cellEditorProvider.getEditor() : undefined; + } + return undefined; + } + + // updateDecorations is only used for modifying decorations on markdown cells + // changeDecorations is the function that handles the decorations w.r.t codeEditor cells. + public updateDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void { + let editorImpl = this._notebookService.findNotebookEditor(this.notebookInput.notebookUri); + if (editorImpl) { + editorImpl.deltaDecorations(newDecorationRange, oldDecorationRange); + } + } + + public changeDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => any): any { + if (!this.notebookInput.notebookFindModel) { + // callback will not be called + return null; + } + return this.notebookInput.notebookFindModel.changeDecorations(callback, undefined); + } + + public deltaDecorations(oldDecorations: string[], newDecorations: IModelDeltaDecoration[]): string[] { + return undefined; + } + + async setNotebookModel(): Promise { + let notebookEditorModel = await this.notebookInput.resolve(); + if (notebookEditorModel && !this.notebookInput.notebookFindModel.notebookModel) { + this._notebookModel = notebookEditorModel.getNotebookModel(); + this.notebookInput.notebookFindModel.notebookModel = this._notebookModel; + } + if (!this.notebookInput.notebookFindModel.findDecorations) { + this.notebookInput.notebookFindModel.setNotebookFindDecorations(this); + } + } + + public async getNotebookModel(): Promise { + if (!this._notebookModel) { + await this.setNotebookModel(); + } + return this._notebookModel; + + } + + public get notebookFindModel(): INotebookFindModel { + return this.notebookInput.notebookFindModel; + } + /** - * Called to create the editor in the parent element. + * @param parent Called to create the editor in the parent element. */ public createEditor(parent: HTMLElement): void { + this._overlay = document.createElement('div'); + this._overlay.className = 'overlayWidgets monaco-editor'; + this._overlay.style.width = '100%'; + this._overlay.style.zIndex = '4'; + + this._findState = new FindReplaceState(); + this._findState.onFindReplaceStateChange(e => this._onFindStateChange(e)); + + this._finder = new FindWidget( + this, + this._findState, + this._contextViewService, + this._keybindingService, + this._contextKeyService, + this._themeService + ); + this._finder.getDomNode().style.visibility = 'hidden'; } /** @@ -52,12 +176,13 @@ export class NotebookEditor extends BaseEditor { * To be called when the container of this editor changes size. */ public layout(dimension: DOM.Dimension): void { + this._currentDimensions = dimension; if (this.notebookInput) { this.notebookInput.doChangeLayout(); } } - public setInput(input: NotebookInput, options: EditorOptions): Promise { + public async setInput(input: NotebookInput, options: EditorOptions): Promise { if (this.input && this.input.matches(input)) { return Promise.resolve(undefined); } @@ -65,9 +190,8 @@ export class NotebookEditor extends BaseEditor { const parentElement = this.getContainer(); super.setInput(input, options, CancellationToken.None); - DOM.clearNode(parentElement); - + await this.setFindInput(parentElement); if (!input.hasBootstrapped) { let container = DOM.$('.notebookEditor'); container.style.height = '100%'; @@ -81,6 +205,16 @@ export class NotebookEditor extends BaseEditor { } } + private async setFindInput(parentElement: HTMLElement): Promise { + parentElement.appendChild(this._overlay); + await this.setNotebookModel(); + if (this._findState.isRevealed) { + this._triggerInputChange(); + } else { + this._findDecorations.clearDecorations(); + } + } + /** * Load the angular components and record for this input that we have done so */ @@ -93,7 +227,7 @@ export class NotebookEditor extends BaseEditor { providerInfo: input.getProviderInfo(), profile: input.connectionProfile }; - bootstrapAngular(this.instantiationService, + bootstrapAngular(this._instantiationService, NotebookModule, this._notebookContainer, NOTEBOOK_SELECTOR, @@ -101,4 +235,168 @@ export class NotebookEditor extends BaseEditor { input ); } + + public getConfiguration() { + return { + layoutInfo: { + width: this._currentDimensions ? this._currentDimensions.width : 0, + height: this._currentDimensions ? this._currentDimensions.height : 0 + } + }; + } + + public layoutOverlayWidget(widget: IOverlayWidget): void { + // no op + } + + public addOverlayWidget(widget: IOverlayWidget): void { + let domNode = widget.getDomNode(); + domNode.style.right = '28px'; + domNode.style.top = '34px'; + this._overlay.appendChild(domNode); + this._findState.change({ isRevealed: false }, false); + } + + public getAction(id: string): IEditorAction { + return this._actionMap[id]; + } + + + private async _onFindStateChange(e: FindReplaceStateChangedEvent): Promise { + if (!this._notebookModel) { + await this.setNotebookModel(); + } + if (this._findCountChangeListener === undefined && this._notebookModel) { + this._findCountChangeListener = this.notebookInput.notebookFindModel.onFindCountChange(() => this._updateFinderMatchState()); + } + if (e.isRevealed) { + if (this._findState.isRevealed) { + this._finder.getDomNode().style.visibility = 'visible'; + this._finder.focusFindInput(); + this._updateFinderMatchState(); + // if find is closed and opened again, highlight the last position. + this._findDecorations.setStartPosition(this.getPosition()); + } else { + this._finder.getDomNode().style.visibility = 'hidden'; + this._findDecorations.clearDecorations(); + } + } + + if (e.searchString) { + this._findDecorations.clearDecorations(); + if (this._notebookModel) { + if (this._findState.searchString) { + let findScope = this._findDecorations.getFindScope(); + if (this._findState.searchString === this.notebookFindModel.findExpression && findScope !== null) { + if (findScope) { + this._updateFinderMatchState(); + this._findState.changeMatchInfo( + this.notebookFindModel.getFindIndex(), + this._findDecorations.getCount(), + this._currentMatch + ); + this._setCurrentFindMatch(findScope); + } + } else { + this.notebookInput.notebookFindModel.clearDecorations(); + this.notebookFindModel.findExpression = this._findState.searchString; + this.notebookInput.notebookFindModel.find(this._findState.searchString, NOTEBOOK_MAX_MATCHES).then(findRange => { + if (findRange) { + this.updatePosition(findRange); + } else if (this.notebookFindModel.findMatches.length > 0) { + this.updatePosition(this.notebookFindModel.findMatches[0].range); + } else { + this.notebookInput.notebookFindModel.clearFind(); + this._updateFinderMatchState(); + this._finder.focusFindInput(); + return; + } + this._updateFinderMatchState(); + this._finder.focusFindInput(); + this._findDecorations.set(this.notebookFindModel.findMatches, this._currentMatch); + this._findState.changeMatchInfo( + this.notebookFindModel.getFindIndex(), + this._findDecorations.getCount(), + this._currentMatch + ); + this._setCurrentFindMatch(this._currentMatch); + }); + } + } else { + this.notebookFindModel.clearFind(); + } + } + } + } + + public setSelection(range: NotebookRange): void { + this._previousMatch = this._currentMatch; + this._currentMatch = range; + } + public toggleSearch(): void { + this._findState.change({ + isRevealed: !this._findState.isRevealed + }, false); + if (this._findState.isRevealed) { + this._finder.focusFindInput(); + } + } + + public findNext(): void { + this.notebookFindModel.findNext().then(p => { + this.updatePosition(p); + this._updateFinderMatchState(); + this._setCurrentFindMatch(p); + }, er => { onUnexpectedError(er); }); + } + + + public findPrevious(): void { + this.notebookFindModel.findPrevious().then(p => { + this.updatePosition(p); + this._updateFinderMatchState(); + this._setCurrentFindMatch(p); + }, er => { onUnexpectedError(er); }); + } + + private _updateFinderMatchState(): void { + if (this.notebookInput && this.notebookInput.notebookFindModel) { + this._findState.changeMatchInfo(this.notebookFindModel.getFindIndex(), this.notebookFindModel.getFindCount(), this._currentMatch); + } else { + this._findState.changeMatchInfo(0, 0, undefined); + } + } + + private updatePosition(range: NotebookRange): void { + this._previousMatch = this._currentMatch; + this._currentMatch = range; + } + + private _setCurrentFindMatch(match: NotebookRange): void { + if (match) { + this._notebookModel.updateActiveCell(match.cell); + this._findDecorations.setCurrentFindMatch(match); + this.setSelection(match); + } + } + + private _triggerInputChange(): void { + let changeEvent: FindReplaceStateChangedEvent = { + moveCursor: true, + updateHistory: true, + searchString: true, + replaceString: false, + isRevealed: false, + isReplaceRevealed: false, + isRegex: false, + wholeWord: false, + matchCase: false, + preserveCase: false, + searchScope: false, + matchesPosition: false, + matchesCount: false, + currentMatch: false + }; + this._onFindStateChange(changeEvent).catch(e => { onUnexpectedError(e); }); + } } diff --git a/src/sql/workbench/contrib/notebook/find/notebookFindDecorations.ts b/src/sql/workbench/contrib/notebook/find/notebookFindDecorations.ts new file mode 100644 index 0000000000..dea316fc87 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/find/notebookFindDecorations.ts @@ -0,0 +1,324 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from 'vs/base/common/lifecycle'; +import { IModelDecorationsChangeAccessor, IModelDeltaDecoration, OverviewRulerLane, TrackedRangeStickiness, MinimapPosition, FindMatch } from 'vs/editor/common/model'; +import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; +import { overviewRulerFindMatchForeground, minimapFindMatch } from 'vs/platform/theme/common/colorRegistry'; +import { themeColorFromId } from 'vs/platform/theme/common/themeService'; +import { NotebookEditor } from 'sql/workbench/contrib/notebook/browser/notebookEditor'; +import { ICellModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; +import { Range } from 'vs/editor/common/core/range'; +import { ScrollType } from 'vs/editor/common/editorCommon'; + +export class NotebookFindDecorations implements IDisposable { + + private _decorations: string[]; + private _overviewRulerApproximateDecorations: string[]; + private _findScopeDecorationId: string | null; + private _rangeHighlightDecorationId: string | null; + private _highlightedDecorationId: string | null; + private _startPosition: NotebookRange; + private _currentMatch: NotebookRange; + + constructor(private readonly _editor: NotebookEditor) { + this._decorations = []; + this._overviewRulerApproximateDecorations = []; + this._findScopeDecorationId = null; + this._rangeHighlightDecorationId = null; + this._highlightedDecorationId = null; + this._startPosition = this._editor.getPosition(); + } + + public dispose(): void { + this._editor.deltaDecorations(this._allDecorations(), []); + + this._decorations = []; + this._overviewRulerApproximateDecorations = []; + this._findScopeDecorationId = null; + this._rangeHighlightDecorationId = null; + this._highlightedDecorationId = null; + } + + public reset(): void { + this._decorations = []; + this._overviewRulerApproximateDecorations = []; + this._findScopeDecorationId = null; + this._rangeHighlightDecorationId = null; + this._highlightedDecorationId = null; + } + + public getCount(): number { + return this._decorations.length; + } + + public getFindScope(): NotebookRange | null { + if (this._currentMatch) { + return this._currentMatch; + } + return null; + } + + public getStartPosition(): NotebookRange { + return this._startPosition; + } + + public setStartPosition(newStartPosition: NotebookRange): void { + if (newStartPosition) { + this._startPosition = newStartPosition; + this.setCurrentFindMatch(this._startPosition); + } + } + + public clearDecorations(): void { + this.removePrevDecorations(); + } + + public setCurrentFindMatch(nextMatch: NotebookRange | null): number { + let newCurrentDecorationId: string | null = null; + let matchPosition = 0; + if (nextMatch) { + for (let i = 0, len = this._decorations.length; i < len; i++) { + let range = this._editor.notebookFindModel.getDecorationRange(this._decorations[i]); + if (nextMatch.equalsRange(range)) { + newCurrentDecorationId = this._decorations[i]; + this._findScopeDecorationId = newCurrentDecorationId; + matchPosition = (i + 1); + break; + } + } + } + + if (this._highlightedDecorationId !== null || newCurrentDecorationId !== null) { + this.removePrevDecorations(); + if (this.checkValidEditor(nextMatch)) { + this._editor.getCellEditor(nextMatch.cell.cellGuid).getControl().changeDecorations((changeAccessor: IModelDecorationsChangeAccessor) => { + if (this._highlightedDecorationId !== null) { + changeAccessor.changeDecorationOptions(this._highlightedDecorationId, NotebookFindDecorations._FIND_MATCH_DECORATION); + this._highlightedDecorationId = null; + } + if (newCurrentDecorationId !== null) { + this._highlightedDecorationId = newCurrentDecorationId; + changeAccessor.changeDecorationOptions(this._highlightedDecorationId, NotebookFindDecorations._CURRENT_FIND_MATCH_DECORATION); + } + + if (newCurrentDecorationId !== null) { + let rng = this._editor.notebookFindModel.getDecorationRange(newCurrentDecorationId)!; + if (rng.startLineNumber !== rng.endLineNumber && rng.endColumn === 1) { + let lineBeforeEnd = rng.endLineNumber - 1; + let lineBeforeEndMaxColumn = this._editor.notebookFindModel.getLineMaxColumn(lineBeforeEnd); + rng = new NotebookRange(rng.cell, rng.startLineNumber, rng.startColumn, lineBeforeEnd, lineBeforeEndMaxColumn); + } + this._rangeHighlightDecorationId = changeAccessor.addDecoration(rng, NotebookFindDecorations._RANGE_HIGHLIGHT_DECORATION); + this._revealRangeInCenterIfOutsideViewport(nextMatch); + this._currentMatch = nextMatch; + } + }); + } + else { + this._editor.updateDecorations(nextMatch, undefined); + this._currentMatch = nextMatch; + } + } + + return matchPosition; + } + + private removePrevDecorations(): void { + if (this._currentMatch && this._currentMatch.cell) { + let pevEditor = this._editor.getCellEditor(this._currentMatch.cell.cellGuid); + if (pevEditor) { + pevEditor.getControl().changeDecorations((changeAccessor: IModelDecorationsChangeAccessor) => { + changeAccessor.removeDecoration(this._rangeHighlightDecorationId); + this._rangeHighlightDecorationId = null; + }); + } else { + if (this._currentMatch.cell.cellType === 'markdown') { + this._editor.updateDecorations(undefined, this._currentMatch); + } + } + } + } + + private _revealRangeInCenterIfOutsideViewport(match: NotebookRange): void { + let matchEditor = this._editor.getCellEditor(match.cell.cellGuid); + if (matchEditor) { + matchEditor.getContainer().scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + matchEditor.getControl().revealRangeInCenterIfOutsideViewport(match, ScrollType.Smooth); + } + } + + public checkValidEditor(range: NotebookRange): boolean { + return range && range.cell && !!(this._editor.getCellEditor(range.cell.cellGuid)); + } + + public set(findMatches: NotebookFindMatch[], findScope: NotebookRange | null): void { + this._editor.changeDecorations((accessor) => { + + let findMatchesOptions: ModelDecorationOptions = NotebookFindDecorations._FIND_MATCH_DECORATION; + let newOverviewRulerApproximateDecorations: IModelDeltaDecoration[] = []; + + if (findMatches.length > 1000) { + // we go into a mode where the overview ruler gets "approximate" decorations + // the reason is that the overview ruler paints all the decorations in the file and we don't want to cause freezes + findMatchesOptions = NotebookFindDecorations._FIND_MATCH_NO_OVERVIEW_DECORATION; + + // approximate a distance in lines where matches should be merged + const lineCount = this._editor.notebookFindModel.getLineCount(); + const height = this._editor.getConfiguration().layoutInfo.height; + const approxPixelsPerLine = height / lineCount; + const mergeLinesDelta = Math.max(2, Math.ceil(3 / approxPixelsPerLine)); + + // merge decorations as much as possible + let prevStartLineNumber = findMatches[0].range.startLineNumber; + let prevEndLineNumber = findMatches[0].range.endLineNumber; + for (let i = 1, len = findMatches.length; i < len; i++) { + const range: NotebookRange = findMatches[i].range; + if (prevEndLineNumber + mergeLinesDelta >= range.startLineNumber) { + if (range.endLineNumber > prevEndLineNumber) { + prevEndLineNumber = range.endLineNumber; + } + } else { + newOverviewRulerApproximateDecorations.push({ + range: new NotebookRange(range.cell, prevStartLineNumber, 1, prevEndLineNumber, 1), + options: NotebookFindDecorations._FIND_MATCH_ONLY_OVERVIEW_DECORATION + }); + prevStartLineNumber = range.startLineNumber; + prevEndLineNumber = range.endLineNumber; + } + } + + newOverviewRulerApproximateDecorations.push({ + range: new NotebookRange(findMatches[0].range.cell, prevStartLineNumber, 1, prevEndLineNumber, 1), + options: NotebookFindDecorations._FIND_MATCH_ONLY_OVERVIEW_DECORATION + }); + } + + // 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 + }; + } + this._decorations = accessor.deltaDecorations(this._decorations, newFindMatchesDecorations); + + // Overview ruler approximate decorations + this._overviewRulerApproximateDecorations = accessor.deltaDecorations(this._overviewRulerApproximateDecorations, newOverviewRulerApproximateDecorations); + + // Range highlight + if (this._rangeHighlightDecorationId) { + accessor.removeDecoration(this._rangeHighlightDecorationId); + this._rangeHighlightDecorationId = null; + } + + // Find scope + if (this._findScopeDecorationId) { + accessor.removeDecoration(this._findScopeDecorationId); + this._findScopeDecorationId = null; + } + if (findScope) { + this._currentMatch = findScope; + this._findScopeDecorationId = accessor.addDecoration(findScope, NotebookFindDecorations._FIND_SCOPE_DECORATION); + } + }); + } + + private _allDecorations(): string[] { + let result: string[] = []; + result = result.concat(this._decorations); + result = result.concat(this._overviewRulerApproximateDecorations); + if (this._findScopeDecorationId) { + result.push(this._findScopeDecorationId); + } + if (this._rangeHighlightDecorationId) { + result.push(this._rangeHighlightDecorationId); + } + return result; + } + + private static readonly _CURRENT_FIND_MATCH_DECORATION = ModelDecorationOptions.register({ + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + zIndex: 13, + className: 'currentFindMatch', + showIfCollapsed: true, + overviewRuler: { + color: themeColorFromId(overviewRulerFindMatchForeground), + position: OverviewRulerLane.Center + }, + minimap: { + color: themeColorFromId(minimapFindMatch), + position: MinimapPosition.Inline + } + }); + + private static readonly _FIND_MATCH_DECORATION = ModelDecorationOptions.register({ + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + className: 'findMatch', + showIfCollapsed: true, + overviewRuler: { + color: themeColorFromId(overviewRulerFindMatchForeground), + position: OverviewRulerLane.Center + }, + minimap: { + color: themeColorFromId(minimapFindMatch), + position: MinimapPosition.Inline + } + }); + + private static readonly _FIND_MATCH_NO_OVERVIEW_DECORATION = ModelDecorationOptions.register({ + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + className: 'findMatch', + showIfCollapsed: true + }); + + private static readonly _FIND_MATCH_ONLY_OVERVIEW_DECORATION = ModelDecorationOptions.register({ + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + overviewRuler: { + color: themeColorFromId(overviewRulerFindMatchForeground), + position: OverviewRulerLane.Center + } + }); + + private static readonly _RANGE_HIGHLIGHT_DECORATION = ModelDecorationOptions.register({ + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + className: 'rangeHighlight', + isWholeLine: false + }); + + private static readonly _FIND_SCOPE_DECORATION = ModelDecorationOptions.register({ + className: 'findScope', + isWholeLine: true + }); +} + +export class NotebookRange extends Range { + updateActiveCell(cell: ICellModel) { + this.cell = cell; + } + cell: ICellModel; + + constructor(cell: ICellModel, startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) { + super(startLineNumber, startColumn, endLineNumber, endColumn); + this.updateActiveCell(cell); + } +} + +export class NotebookFindMatch extends FindMatch { + _findMatchBrand: void; + + public readonly range: NotebookRange; + public readonly matches: string[] | null; + + /** + * @internal + */ + constructor(range: NotebookRange, matches: string[] | null) { + super(new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn), matches); + this.range = range; + this.matches = matches; + } +} diff --git a/src/sql/workbench/contrib/notebook/find/notebookFindModel.ts b/src/sql/workbench/contrib/notebook/find/notebookFindModel.ts new file mode 100644 index 0000000000..6ec53c7e9d --- /dev/null +++ b/src/sql/workbench/contrib/notebook/find/notebookFindModel.ts @@ -0,0 +1,669 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { INotebookFindModel, ICellModel, INotebookModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; +import { Event, Emitter } from 'vs/base/common/event'; +import * as types from 'vs/base/common/types'; +import { NotebookRange, NotebookFindMatch, NotebookFindDecorations } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; +import * as model from 'vs/editor/common/model'; +import { ModelDecorationOptions, DidChangeDecorationsEmitter, createTextBuffer } from 'vs/editor/common/model/textModel'; +import { IModelDecorationsChangedEvent } from 'vs/editor/common/model/textModelEvents'; +import { IntervalNode } from 'vs/editor/common/model/intervalTree'; +import { EDITOR_MODEL_DEFAULTS } from 'vs/editor/common/config/editorOptions'; +import { Range, IRange } from 'vs/editor/common/core/range'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { singleLetterHash, isHighSurrogate } from 'vs/base/common/strings'; +import { Command, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { NotebookEditor } from 'sql/workbench/contrib/notebook/browser/notebookEditor'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { NOTEBOOK_COMMAND_SEARCH, NotebookEditorVisibleContext } from 'sql/workbench/services/notebook/common/notebookContext'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; + +function _normalizeOptions(options: model.IModelDecorationOptions): ModelDecorationOptions { + if (options instanceof ModelDecorationOptions) { + return options; + } + return ModelDecorationOptions.createDynamic(options); +} + +const invalidFunc = () => { throw new Error(`Invalid change accessor`); }; + +let MODEL_ID = 0; + +export class NotebookFindModel extends Disposable implements INotebookFindModel { + + private _findArray: Array; + private _findIndex: number = 0; + private _onFindCountChange = new Emitter(); + private _isDisposed: boolean; + public readonly id: string; + private _buffer: model.ITextBuffer; + private readonly _instanceId: string; + private _lastDecorationId: number; + private _versionId: number; + private _findDecorations: NotebookFindDecorations; + public currentMatch: NotebookRange; + public previousMatch: NotebookRange; + public findExpression: string; + + //#region Decorations + private readonly _onDidChangeDecorations: DidChangeDecorationsEmitter = this._register(new DidChangeDecorationsEmitter()); + public readonly onDidChangeDecorations: Event = this._onDidChangeDecorations.event; + private _decorations: { [decorationId: string]: NotebookIntervalNode; }; + //#endregion + + constructor(private _notebookModel: INotebookModel) { + super(); + + this._isDisposed = false; + + this._instanceId = singleLetterHash(MODEL_ID); + this._lastDecorationId = 0; + // Generate a new unique model id + MODEL_ID++; + + this._decorations = Object.create(null); + + this._buffer = createTextBuffer('', NotebookFindModel.DEFAULT_CREATION_OPTIONS.defaultEOL); + this._versionId = 1; + this.id = '$model' + MODEL_ID; + } + + public set notebookModel(model: INotebookModel) { + this._notebookModel = model; + } + + public get notebookModel(): INotebookModel { + return this._notebookModel; + } + + public get findDecorations(): NotebookFindDecorations { + return this._findDecorations; + } + + public setNotebookFindDecorations(editor: NotebookEditor): void { + this._findDecorations = new NotebookFindDecorations(editor); + this._findDecorations.setStartPosition(this.getPosition()); + } + + public clearDecorations(): void { + this._findDecorations.dispose(); + this.clearFind(); + } + + public static DEFAULT_CREATION_OPTIONS: model.ITextModelCreationOptions = { + isForSimpleWidget: false, + tabSize: EDITOR_MODEL_DEFAULTS.tabSize, + indentSize: EDITOR_MODEL_DEFAULTS.indentSize, + insertSpaces: EDITOR_MODEL_DEFAULTS.insertSpaces, + detectIndentation: false, + defaultEOL: model.DefaultEndOfLine.LF, + trimAutoWhitespace: EDITOR_MODEL_DEFAULTS.trimAutoWhitespace, + largeFileOptimizations: EDITOR_MODEL_DEFAULTS.largeFileOptimizations, + }; + + public get onFindCountChange(): Event { return this._onFindCountChange.event; } + + public get VersionId(): number { + return this._versionId; + } + + public get Buffer(): model.ITextBuffer { + return this._buffer; + } + + public get ModelId(): number { + return MODEL_ID; + } + + public getPosition(): NotebookRange { + return this.currentMatch; + } + + public getLastPosition(): NotebookRange { + return this.previousMatch; + } + + public setSelection(range: NotebookRange): void { + this.previousMatch = this.currentMatch; + this.currentMatch = range; + } + + public ChangeDecorations(ownerId: number, callback: (changeAccessor: model.IModelDecorationsChangeAccessor) => T): T | null { + let changeAccessor: model.IModelDecorationsChangeAccessor = { + addDecoration: (range: IRange, options: model.IModelDecorationOptions): string => { + this._onDidChangeDecorations.fire(); + return this._deltaDecorationsImpl(ownerId, [], [{ range: range, options: options }])[0]; + }, + changeDecoration: (id: string, newRange: IRange): void => { + this._onDidChangeDecorations.fire(); + this._changeDecorationImpl(id, newRange); + }, + changeDecorationOptions: (id: string, options: model.IModelDecorationOptions) => { + this._onDidChangeDecorations.fire(); + this._changeDecorationOptionsImpl(id, _normalizeOptions(options)); + }, + removeDecoration: (id: string): void => { + this._onDidChangeDecorations.fire(); + this._deltaDecorationsImpl(ownerId, [id], []); + }, + deltaDecorations: (oldDecorations: string[], newDecorations: model.IModelDeltaDecoration[]): string[] => { + if (oldDecorations.length === 0 && newDecorations.length === 0) { + // nothing to do + return []; + } + this._onDidChangeDecorations.fire(); + return this._deltaDecorationsImpl(ownerId, oldDecorations, newDecorations); + } + }; + let result: T | null = null; + try { + result = callback(changeAccessor); + } catch (e) { + onUnexpectedError(e); + } + // Invalidate change accessor + changeAccessor.addDecoration = invalidFunc; + changeAccessor.changeDecoration = invalidFunc; + changeAccessor.changeDecorationOptions = invalidFunc; + changeAccessor.removeDecoration = invalidFunc; + changeAccessor.deltaDecorations = invalidFunc; + return result; + } + + public getRangeAt(cell: ICellModel, start: number, end: number): NotebookRange { + return this._getRangeAt(cell, start, end); + } + + private _getRangeAt(cell: ICellModel, start: number, end: number): NotebookRange { + let range: Range = this._buffer.getRangeAt(start, end - start); + return new NotebookRange(cell, range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn); + } + + /** + * @param range the range to check for validity + * @param strict Do NOT allow a range to have its boundaries inside a high-low surrogate pair + */ + private _isValidRange(range: NotebookRange, strict: boolean): boolean { + const startLineNumber = range.startLineNumber; + const startColumn = range.startColumn; + const endLineNumber = range.endLineNumber; + const endColumn = range.endColumn; + + if (!this._isValidPosition(startLineNumber, startColumn, false)) { + return false; + } + if (!this._isValidPosition(endLineNumber, endColumn, false)) { + return false; + } + + if (strict) { + const charCodeBeforeStart = (startColumn > 1 ? this._buffer.getLineCharCode(startLineNumber, startColumn - 2) : 0); + const charCodeBeforeEnd = (endColumn > 1 && endColumn <= this._buffer.getLineLength(endLineNumber) ? this._buffer.getLineCharCode(endLineNumber, endColumn - 2) : 0); + + const startInsideSurrogatePair = isHighSurrogate(charCodeBeforeStart); + const endInsideSurrogatePair = isHighSurrogate(charCodeBeforeEnd); + + if (!startInsideSurrogatePair && !endInsideSurrogatePair) { + return true; + } + + return false; + } + + return true; + } + + private _isValidPosition(lineNumber: number, column: number, strict: boolean): boolean { + if (typeof lineNumber !== 'number' || typeof column !== 'number') { + return false; + } + + if (isNaN(lineNumber) || isNaN(column)) { + return false; + } + + if (lineNumber < 0 || column < 1) { + return false; + } + + if ((lineNumber | 0) !== lineNumber || (column | 0) !== column) { + return false; + } + + const lineCount = this._buffer.getLineCount(); + if (lineNumber > lineCount) { + return false; + } + + if (strict) { + if (column > 1) { + const charCodeBefore = this._buffer.getLineCharCode(lineNumber, column - 2); + if (isHighSurrogate(charCodeBefore)) { + return false; + } + } + } + + return true; + } + + getLineMaxColumn(lineNumber: number): number { + if (lineNumber < 1 || lineNumber > this.getLineCount()) { + throw new Error('Illegal value for lineNumber'); + } + return this._buffer.getLineLength(lineNumber) + 1; + } + + getLineCount(): number { + return this._buffer.getLineCount(); + } + + public getVersionId(): number { + return this._versionId; + } + + public validateRange(_range: IRange): NotebookRange { + // Avoid object allocation and cover most likely case + if ((_range instanceof NotebookRange) && !(_range instanceof Selection)) { + if (this._isValidRange(_range, true)) { + return _range; + } + } + + return undefined; + } + + /** + * Validates `range` is within buffer bounds, but allows it to sit in between surrogate pairs, etc. + * Will try to not allocate if possible. + */ + private _validateRangeRelaxedNoAllocations(range: IRange): NotebookRange { + if (range instanceof NotebookRange) { + this._buffer = createTextBuffer(range.cell.source instanceof Array ? range.cell.source.join('\n') : range.cell.source, NotebookFindModel.DEFAULT_CREATION_OPTIONS.defaultEOL); + } + + const linesCount = this._buffer.getLineCount(); + + const initialStartLineNumber = range.startLineNumber; + const initialStartColumn = range.startColumn; + let startLineNumber: number; + let startColumn: number; + + if (initialStartLineNumber < 1) { + startLineNumber = 1; + startColumn = 1; + } else if (initialStartLineNumber > linesCount) { + startLineNumber = linesCount; + startColumn = this.getLineMaxColumn(startLineNumber); + } else { + startLineNumber = initialStartLineNumber | 0; + if (initialStartColumn <= 1) { + startColumn = 1; + } else { + const maxColumn = this.getLineMaxColumn(startLineNumber); + if (initialStartColumn >= maxColumn) { + startColumn = maxColumn; + } else { + startColumn = initialStartColumn | 0; + } + } + } + + const initialEndLineNumber = range.endLineNumber; + const initialEndColumn = range.endColumn; + let endLineNumber: number; + let endColumn: number; + + if (initialEndLineNumber < 1) { + endLineNumber = 1; + endColumn = 1; + } else if (initialEndLineNumber > linesCount) { + endLineNumber = linesCount; + endColumn = this.getLineMaxColumn(endLineNumber); + } else { + endLineNumber = initialEndLineNumber | 0; + if (initialEndColumn <= 1) { + endColumn = 1; + } else { + const maxColumn = this.getLineMaxColumn(endLineNumber); + if (initialEndColumn >= maxColumn) { + endColumn = maxColumn; + } else { + endColumn = initialEndColumn | 0; + } + } + } + + if ( + initialStartLineNumber === startLineNumber + && initialStartColumn === startColumn + && initialEndLineNumber === endLineNumber + && initialEndColumn === endColumn + && range instanceof NotebookRange + && !(range instanceof Selection) + ) { + return range; + } + + if (range instanceof NotebookRange) { + return range; + } + return new NotebookRange(undefined, startLineNumber, startColumn, endLineNumber, endColumn); + } + + private _changeDecorationImpl(decorationId: string, _range: IRange): void { + const node = this._decorations[decorationId]; + if (!node) { + return; + } + const range = this._validateRangeRelaxedNoAllocations(_range); + const startOffset = this._buffer.getOffsetAt(range.startLineNumber, range.startColumn); + const endOffset = this._buffer.getOffsetAt(range.endLineNumber, range.endColumn); + node.node.reset(this.getVersionId(), startOffset, endOffset, range); + } + + private _changeDecorationOptionsImpl(decorationId: string, options: ModelDecorationOptions): void { + const node = this._decorations[decorationId]; + if (!node) { + return; + } + + const nodeWasInOverviewRuler = (node.node.options.overviewRuler && node.node.options.overviewRuler.color ? true : false); + const nodeIsInOverviewRuler = (options.overviewRuler && options.overviewRuler.color ? true : false); + + if (nodeWasInOverviewRuler !== nodeIsInOverviewRuler) { + // Delete + Insert due to an overview ruler status change + node.node.setOptions(options); + } else { + node.node.setOptions(options); + } + } + + private _deltaDecorationsImpl(ownerId: number, oldDecorationsIds: string[], newDecorations: model.IModelDeltaDecoration[]): string[] { + const versionId = this.getVersionId(); + + + const oldDecorationsLen = oldDecorationsIds.length; + let oldDecorationIndex = 0; + + const newDecorationsLen = newDecorations.length; + let newDecorationIndex = 0; + + let result = new Array(newDecorationsLen); + while (oldDecorationIndex < oldDecorationsLen || newDecorationIndex < newDecorationsLen) { + + let node: IntervalNode | null = null; + let cell: ICellModel | null = null; + + if (oldDecorationIndex < oldDecorationsLen) { + // (1) get ourselves an old node + do { + node = this._decorations[oldDecorationsIds[oldDecorationIndex++]].node; + } while (!node && oldDecorationIndex < oldDecorationsLen); + + // (2) remove the node from the tree (if it exists) + if (node) { + //this._decorationsTree.delete(node); + } + } + + if (newDecorationIndex < newDecorationsLen) { + // (3) create a new node if necessary + if (!node) { + const internalDecorationId = (++this._lastDecorationId); + const decorationId = `${this._instanceId};${internalDecorationId}`; + node = new IntervalNode(decorationId, 0, 0); + this._decorations[decorationId] = new NotebookIntervalNode(node, cell); + } + + // (4) initialize node + const newDecoration = newDecorations[newDecorationIndex]; + const range = this._validateRangeRelaxedNoAllocations(newDecoration.range); + const options = _normalizeOptions(newDecoration.options); + const startOffset = this._buffer.getOffsetAt(range.startLineNumber, range.startColumn); + const endOffset = this._buffer.getOffsetAt(range.endLineNumber, range.endColumn); + + node.ownerId = ownerId; + node.reset(versionId, startOffset, endOffset, range); + node.setOptions(options); + + this._decorations[node.id].cell = range.cell; + this._decorations[node.id].node = node; + //this._decorationsTree.insert(node); + + result[newDecorationIndex] = node.id; + + newDecorationIndex++; + } else { + if (node) { + delete this._decorations[node.id]; + } + } + } + + return result; + } + + public getDecorationRange(id: string): NotebookRange | null { + const node = this._decorations[id]; + if (!node) { + return null; + } + + let range = node.node.range; + if (range === null) { + node.node.range = this._getRangeAt(node.cell, node.node.cachedAbsoluteStart, node.node.cachedAbsoluteEnd); + } + return new NotebookRange(node.cell, node.node.range.startLineNumber, node.node.range.startColumn, node.node.range.endLineNumber, node.node.range.endColumn); + } + + + findNext(): Promise { + if (this._findArray && this._findArray.length !== 0) { + if (this._findIndex === this._findArray.length - 1) { + this._findIndex = 0; + } else { + ++this._findIndex; + } + return Promise.resolve(this._findArray[this._findIndex]); + } else { + return Promise.reject(new Error('no search running')); + } + } + + findPrevious(): Promise { + if (this._findArray && this._findArray.length !== 0) { + if (this._findIndex === 0) { + this._findIndex = this._findArray.length - 1; + } else { + --this._findIndex; + } + return Promise.resolve(this._findArray[this._findIndex]); + } else { + return Promise.reject(new Error('no search running')); + } + } + + find(exp: string, maxMatches?: number): Promise { + this._findArray = new Array(); + this._onFindCountChange.fire(this._findArray.length); + if (exp) { + return new Promise((resolve) => { + const disp = this.onFindCountChange(e => { + resolve(this._findArray[this._findIndex]); + disp.dispose(); + }); + this._startSearch(exp, maxMatches); + }); + } else { + return Promise.reject(new Error('no expression')); + } + } + + public get findMatches(): NotebookFindMatch[] { + let findMatches: NotebookFindMatch[] = []; + this._findArray.forEach(element => { + findMatches = findMatches.concat(new NotebookFindMatch(element, null)); + }); + return findMatches; + } + + public get findArray(): NotebookRange[] { + return this.findArray; + } + + private _startSearch(exp: string, maxMatches: number = 0): void { + let searchFn = (cell: ICellModel, exp: string): NotebookRange[] => { + let findResults: NotebookRange[] = []; + let cellVal = cell.cellType === 'markdown' ? this.cleanUpCellSource(cell.source) : cell.source; + let index: number; + let start: number; + let end: number; + if (cellVal) { + if (typeof cellVal === 'string') { + index = 0; + while (cellVal.substr(index).toLocaleLowerCase().indexOf(exp.toLocaleLowerCase()) > -1) { + start = cellVal.substr(index).toLocaleLowerCase().indexOf(exp.toLocaleLowerCase()) + index; + end = start + exp.length; + let range = new NotebookRange(cell, 0, start, 0, end); + findResults = findResults.concat(range); + index = end; + } + } else { + for (let j = 0; j < cellVal.length; j++) { + index = 0; + let cellValFormatted = cell.cellType === 'markdown' ? this.cleanMarkdownLinks(cellVal[j]) : cellVal[j]; + while (cellValFormatted.substr(index).toLocaleLowerCase().indexOf(exp.toLocaleLowerCase()) > -1) { + start = cellValFormatted.substr(index).toLocaleLowerCase().indexOf(exp.toLocaleLowerCase()) + index + 1; + end = start + exp.length; + // lineNumber: j+1 since notebook editors aren't zero indexed. + let range = new NotebookRange(cell, j + 1, start, j + 1, end); + findResults = findResults.concat(range); + index = end; + } + } + } + } + return findResults; + }; + for (let i = 0; i < this.notebookModel.cells.length; i++) { + const item = this.notebookModel.cells[i]; + const result = searchFn!(item, exp); + if (result) { + this._findArray = this._findArray.concat(result); + this._onFindCountChange.fire(this._findArray.length); + if (maxMatches > 0 && this._findArray.length === maxMatches) { + break; + } + } + } + } + + // In markdown links are defined as [Link Text](https://url/of/the/text). when searching for text we shouldn't + // look for the values inside the (), below regex replaces that with just the Link Text. + cleanMarkdownLinks(cellSrc: string): string { + return cellSrc.replace(/(?:__|[*#])|\[(.*?)\]\(.*?\)/gm, '$1'); + } + + // remove /n's to calculate the line number to locate the correct element + cleanUpCellSource(cellValue: string | string[]): string | string[] { + let trimmedCellSrc: string[] = []; + if (cellValue instanceof Array) { + trimmedCellSrc = cellValue.filter(c => c !== '\n' && c !== '\r\n' && c.indexOf('|-') === -1); + } else { + return cellValue; + } + return trimmedCellSrc; + } + + clearFind(): void { + this._findArray = new Array(); + this._findIndex = 0; + this._onFindCountChange.fire(this._findArray.length); + } + + getFindIndex(): number { + return types.isUndefinedOrNull(this._findIndex) ? 0 : this._findIndex + 1; + } + + getFindCount(): number { + return types.isUndefinedOrNull(this._findArray) ? 0 : this._findArray.length; + } + + + //#region Decorations + + public isDisposed(): boolean { + return this._isDisposed; + } + + private _assertNotDisposed(): void { + if (this._isDisposed) { + throw new Error('Model is disposed!'); + } + } + + public changeDecorations(callback: (changeAccessor: model.IModelDecorationsChangeAccessor) => T, ownerId: number = 0): T | null { + this._assertNotDisposed(); + + try { + this._onDidChangeDecorations.beginDeferredEmit(); + return this.ChangeDecorations(ownerId, callback); + } finally { + this._onDidChangeDecorations.endDeferredEmit(); + } + } + + public dispose(): void { + super.dispose(); + this._findArray = []; + this._isDisposed = true; + } + +} + +export class NotebookIntervalNode { + + constructor(public node: IntervalNode, public cell: ICellModel) { + + } +} + +abstract class SettingsCommand extends Command { + + protected getNotebookEditor(accessor: ServicesAccessor): NotebookEditor { + const activeEditor = accessor.get(IEditorService).activeControl; + if (activeEditor instanceof NotebookEditor) { + return activeEditor; + } + return null; + } + +} + +class SearchNotebookCommand extends SettingsCommand { + + public runCommand(accessor: ServicesAccessor, args: any): void { + const notebookEditor = this.getNotebookEditor(accessor); + if (notebookEditor) { + notebookEditor.toggleSearch(); + } + } + +} + +export const findCommand = new SearchNotebookCommand({ + id: NOTEBOOK_COMMAND_SEARCH, + precondition: ContextKeyExpr.and(NotebookEditorVisibleContext), + kbOpts: { + primary: KeyMod.CtrlCmd | KeyCode.KEY_F, + weight: KeybindingWeight.EditorContrib + } +}); +findCommand.register(); diff --git a/src/sql/workbench/contrib/notebook/find/notebookFindWidget.ts b/src/sql/workbench/contrib/notebook/find/notebookFindWidget.ts new file mode 100644 index 0000000000..cc880b0ebf --- /dev/null +++ b/src/sql/workbench/contrib/notebook/find/notebookFindWidget.ts @@ -0,0 +1,574 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { onUnexpectedError } from 'vs/base/common/errors'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import * as platform from 'vs/base/common/platform'; +import * as strings from 'vs/base/common/strings'; +import * as dom from 'vs/base/browser/dom'; +import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { IMouseEvent } from 'vs/base/browser/mouseEvent'; +import { IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; +import { FindInput, IFindInputStyles } from 'vs/base/browser/ui/findinput/findInput'; +import { IMessage as InputBoxMessage } from 'vs/base/browser/ui/inputbox/inputBox'; +import { Widget } from 'vs/base/browser/ui/widget'; +import { Sash, IHorizontalSashLayoutProvider, ISashEvent, Orientation } from 'vs/base/browser/ui/sash/sash'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference } from 'vs/editor/browser/editorBrowser'; +import { FIND_IDS, CONTEXT_FIND_INPUT_FOCUSED } from 'vs/editor/contrib/find/findModel'; +import { FindReplaceState, FindReplaceStateChangedEvent } from 'vs/editor/contrib/find/findState'; +import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { ITheme, IThemeService } from 'vs/platform/theme/common/themeService'; +import * as colors from 'vs/platform/theme/common/colorRegistry'; +import { IEditorAction } from 'vs/editor/common/editorCommon'; +import { IDisposable } from 'vs/base/common/lifecycle'; + +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_MATCHES_COUNT_LIMIT_TITLE = nls.localize('title.matchesCountLimit', "Your search returned a large number of results, only the first 999 matches will be highlighted."); +const NLS_MATCHES_LOCATION = nls.localize('label.matchesLocation', "{0} of {1}"); +const NLS_NO_RESULTS = nls.localize('label.noResults', "No Results"); + +const FIND_WIDGET_INITIAL_WIDTH = 411; +const PART_WIDTH = 275; +const FIND_INPUT_AREA_WIDTH = PART_WIDTH - 54; + +let MAX_MATCHES_COUNT_WIDTH = 69; + +export const NOTEBOOK_MAX_MATCHES = 999; + +export const ACTION_IDS = { + FIND_NEXT: 'findNext', + FIND_PREVIOUS: 'findPrev' +}; + +export interface IFindNotebookController { + focus(): void; + getConfiguration(): any; + layoutOverlayWidget(widget: IOverlayWidget): void; + addOverlayWidget(widget: IOverlayWidget): void; + getAction(id: string): IEditorAction; + onDidChangeConfiguration(fn: (e: IConfigurationChangedEvent) => void): IDisposable; + findNext(); + findPrevious(); +} + +export interface IConfigurationChangedEvent { + layoutInfo?: boolean; +} + +export class FindWidget extends Widget implements IOverlayWidget, IHorizontalSashLayoutProvider { + private static ID = 'editor.contrib.findWidget'; + private _notebookController: IFindNotebookController; + private _state: FindReplaceState; + private _contextViewProvider: IContextViewProvider; + private _keybindingService: IKeybindingService; + + private _domNode: HTMLElement; + private _findInput: FindInput; + + private _matchesCount: HTMLElement; + private _prevBtn: SimpleButton; + private _nextBtn: SimpleButton; + private _closeBtn: SimpleButton; + + private _isVisible: boolean; + + private _focusTracker: dom.IFocusTracker; + private _findInputFocussed: IContextKey; + + private _resizeSash: Sash; + + private searchTimeoutHandle: number | undefined; + + constructor( + notebookController: IFindNotebookController, + state: FindReplaceState, + contextViewProvider: IContextViewProvider, + keybindingService: IKeybindingService, + contextKeyService: IContextKeyService, + themeService: IThemeService + ) { + super(); + this._notebookController = notebookController; + this._state = state; + this._contextViewProvider = contextViewProvider; + this._keybindingService = keybindingService; + + this._isVisible = false; + + this._register(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e))); + this._buildDomNode(); + this._updateButtons(); + + let checkEditorWidth = () => { + let editorWidth = this._notebookController.getConfiguration().layoutInfo.width; + const minimapWidth = this._notebookController.getConfiguration().layoutInfo.minimapWidth; + let collapsedFindWidget = false; + let reducedFindWidget = false; + let narrowFindWidget = false; + let widgetWidth = dom.getTotalWidth(this._domNode); + + if (widgetWidth > FIND_WIDGET_INITIAL_WIDTH) { + // as the widget is resized by users, we may need to change the max width of the widget as the editor width changes. + this._domNode.style.maxWidth = `${editorWidth - 28 - 15}px`; + return; + } + + if (FIND_WIDGET_INITIAL_WIDTH + 28 + minimapWidth >= editorWidth) { + reducedFindWidget = true; + } + if (FIND_WIDGET_INITIAL_WIDTH + 28 + minimapWidth - MAX_MATCHES_COUNT_WIDTH >= editorWidth) { + narrowFindWidget = true; + } + if (FIND_WIDGET_INITIAL_WIDTH + 28 + minimapWidth - MAX_MATCHES_COUNT_WIDTH >= editorWidth + 50) { + collapsedFindWidget = true; + } + dom.toggleClass(this._domNode, 'collapsed-find-widget', collapsedFindWidget); + dom.toggleClass(this._domNode, 'narrow-find-widget', narrowFindWidget); + dom.toggleClass(this._domNode, 'reduced-find-widget', reducedFindWidget); + + if (!narrowFindWidget && !collapsedFindWidget) { + // the minimal left offset of findwidget is 15px. + this._domNode.style.maxWidth = `${editorWidth - 28 - 15}px`; + } + + }; + checkEditorWidth(); + + this._register(this._notebookController.onDidChangeConfiguration((e: IConfigurationChangedEvent) => { + if (e.layoutInfo) { + checkEditorWidth(); + } + })); + + this._findInputFocussed = CONTEXT_FIND_INPUT_FOCUSED.bindTo(contextKeyService); + this._focusTracker = this._register(dom.trackFocus(this._findInput.inputBox.inputElement)); + this._focusTracker.onDidFocus(() => { + this._findInputFocussed.set(true); + }); + this._focusTracker.onDidBlur(() => { + this._findInputFocussed.set(false); + }); + + this._notebookController.addOverlayWidget(this); + + this._applyTheme(themeService.getTheme()); + this._register(themeService.onThemeChange(this._applyTheme.bind(this))); + } + + // ----- IOverlayWidget API + + public getId(): string { + return FindWidget.ID; + } + + public getDomNode(): HTMLElement { + return this._domNode; + } + + public getPosition(): IOverlayWidgetPosition { + if (this._isVisible) { + return { + preference: OverlayWidgetPositionPreference.TOP_RIGHT_CORNER + }; + } + return null; + } + + // ----- React to state changes + + private _onStateChanged(e: FindReplaceStateChangedEvent): void { + if (e.searchString) { + this._findInput.setValue(this._state.searchString); + this._updateButtons(); + } + if (e.isRevealed) { + if (this._state.isRevealed) { + this._reveal(true); + } else { + this._hide(true); + } + } + if (e.isRegex) { + this._findInput.setRegex(this._state.isRegex); + } + if (e.wholeWord) { + this._findInput.setWholeWords(this._state.wholeWord); + } + if (e.matchCase) { + this._findInput.setCaseSensitive(this._state.matchCase); + } + if (e.searchString || e.matchesCount || e.matchesPosition) { + let showRedOutline = (this._state.searchString.length > 0 && this._state.matchesCount === 0); + dom.toggleClass(this._domNode, 'no-results', showRedOutline); + + this._updateMatchesCount(); + } + } + + private _updateMatchesCount(): void { + this._matchesCount.style.minWidth = MAX_MATCHES_COUNT_WIDTH + 'px'; + if (this._state.matchesCount >= NOTEBOOK_MAX_MATCHES) { + this._matchesCount.title = NLS_MATCHES_COUNT_LIMIT_TITLE; + } else { + this._matchesCount.title = ''; + } + + // remove previous content + if (this._matchesCount.firstChild) { + this._matchesCount.removeChild(this._matchesCount.firstChild); + } + + let label: string; + if (this._state.matchesCount > 0) { + let matchesCount: string = String(this._state.matchesCount); + if (this._state.matchesCount >= NOTEBOOK_MAX_MATCHES) { + matchesCount = NOTEBOOK_MAX_MATCHES + '+'; + } + let matchesPosition: string = String(this._state.matchesPosition); + if (matchesPosition === '0') { + matchesPosition = '?'; + } + label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); + } else { + label = NLS_NO_RESULTS; + } + this._matchesCount.appendChild(document.createTextNode(label)); + + MAX_MATCHES_COUNT_WIDTH = Math.max(MAX_MATCHES_COUNT_WIDTH, this._matchesCount.clientWidth); + } + + // ----- actions + + private _updateButtons(): void { + this._findInput.setEnabled(this._isVisible); + this._closeBtn.setEnabled(this._isVisible); + + let findInputIsNonEmpty = (this._state.searchString.length > 0); + this._prevBtn.setEnabled(this._isVisible && findInputIsNonEmpty); + this._nextBtn.setEnabled(this._isVisible && findInputIsNonEmpty); + } + + private _reveal(animate: boolean): void { + if (!this._isVisible) { + this._isVisible = true; + + this._updateButtons(); + + setTimeout(() => { + dom.addClass(this._domNode, 'visible'); + this._domNode.setAttribute('aria-hidden', 'false'); + if (!animate) { + dom.addClass(this._domNode, 'noanimation'); + setTimeout(() => { + dom.removeClass(this._domNode, 'noanimation'); + }, 200); + } + }, 0); + this._notebookController.layoutOverlayWidget(this); + } + } + + private _hide(focusTheEditor: boolean): void { + if (this._isVisible) { + this._isVisible = false; + + this._updateButtons(); + + dom.removeClass(this._domNode, 'visible'); + this._domNode.setAttribute('aria-hidden', 'true'); + if (focusTheEditor) { + this._notebookController.focus(); + } + this._notebookController.layoutOverlayWidget(this); + } + } + + private _applyTheme(theme: ITheme) { + let inputStyles: IFindInputStyles = { + inputActiveOptionBorder: theme.getColor(colors.inputActiveOptionBorder), + inputBackground: theme.getColor(colors.inputBackground), + inputForeground: theme.getColor(colors.inputForeground), + inputBorder: theme.getColor(colors.inputBorder), + inputValidationInfoBackground: theme.getColor(colors.inputValidationInfoBackground), + inputValidationInfoBorder: theme.getColor(colors.inputValidationInfoBorder), + inputValidationWarningBackground: theme.getColor(colors.inputValidationWarningBackground), + inputValidationWarningBorder: theme.getColor(colors.inputValidationWarningBorder), + inputValidationErrorBackground: theme.getColor(colors.inputValidationErrorBackground), + inputValidationErrorBorder: theme.getColor(colors.inputValidationErrorBorder) + }; + this._findInput.style(inputStyles); + } + + // ----- Public + + public focusFindInput(): void { + this._findInput.focus(); + } + + public highlightFindOptions(): void { + this._findInput.highlightFindOptions(); + } + + private _onFindInputMouseDown(e: IMouseEvent): void { + // on linux, middle key does pasting. + if (e.middleButton) { + e.stopPropagation(); + } + } + + private _onFindInputKeyDown(e: IKeyboardEvent): void { + + if (e.equals(KeyCode.Enter)) { + this._notebookController.getAction(ACTION_IDS.FIND_NEXT).run().then(null, onUnexpectedError); + e.preventDefault(); + return; + } + + if (e.equals(KeyMod.Shift | KeyCode.Enter)) { + this._notebookController.getAction(ACTION_IDS.FIND_PREVIOUS).run().then(null, onUnexpectedError); + e.preventDefault(); + return; + } + + if (e.equals(KeyCode.Tab)) { + this._findInput.focusOnCaseSensitive(); + e.preventDefault(); + return; + } + + if (e.equals(KeyMod.CtrlCmd | KeyCode.DownArrow)) { + this._notebookController.focus(); + e.preventDefault(); + return; + } + } + + // ----- sash + public getHorizontalSashTop(sash: Sash): number { + return 0; + } + public getHorizontalSashLeft?(sash: Sash): number { + return 0; + } + public getHorizontalSashWidth?(sash: Sash): number { + return 500; + } + + // ----- initialization + + private _keybindingLabelFor(actionId: string): string { + let kb = this._keybindingService.lookupKeybinding(actionId); + if (!kb) { + return ''; + } + return ` (${kb.getLabel()})`; + } + + private _buildFindPart(): HTMLElement { + // Find input + this._findInput = this._register(new FindInput(null, this._contextViewProvider, true, { + width: FIND_INPUT_AREA_WIDTH, + label: NLS_FIND_INPUT_LABEL, + placeholder: NLS_FIND_INPUT_PLACEHOLDER, + appendCaseSensitiveLabel: this._keybindingLabelFor(FIND_IDS.ToggleCaseSensitiveCommand), + appendWholeWordsLabel: this._keybindingLabelFor(FIND_IDS.ToggleWholeWordCommand), + appendRegexLabel: this._keybindingLabelFor(FIND_IDS.ToggleRegexCommand), + validation: (value: string): InputBoxMessage => { + if (value.length === 0) { + return null; + } + if (!this._findInput.getRegex()) { + return null; + } + try { + /* tslint:disable:no-unused-expression */ + new RegExp(value); + /* tslint:enable:no-unused-expression */ + return null; + } catch (e) { + return { content: e.message }; + } + } + })); + this._findInput.setRegex(!!this._state.isRegex); + this._findInput.setCaseSensitive(!!this._state.matchCase); + this._findInput.setWholeWords(!!this._state.wholeWord); + this._register(this._findInput.onKeyDown((e) => this._onFindInputKeyDown(e))); + this._register(this._findInput.onInput(() => { + let self = this; + if (self.searchTimeoutHandle) { + window.clearTimeout(self.searchTimeoutHandle); + } + + this.searchTimeoutHandle = window.setTimeout(function () { + self._state.change({ searchString: self._findInput.getValue() }, true); + }, 300); + })); + this._register(this._findInput.onDidOptionChange(() => { + this._state.change({ + isRegex: this._findInput.getRegex(), + wholeWord: this._findInput.getWholeWords(), + matchCase: this._findInput.getCaseSensitive() + }, true); + })); + if (platform.isLinux) { + this._register(this._findInput.onMouseDown((e) => this._onFindInputMouseDown(e))); + } + + this._matchesCount = document.createElement('div'); + this._matchesCount.className = 'matchesCount'; + this._updateMatchesCount(); + + // Previous button + this._prevBtn = this._register(new SimpleButton({ + label: NLS_PREVIOUS_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.PreviousMatchFindAction), + className: 'codicon codicon-arrow-up', + onTrigger: () => { + this._notebookController.getAction(ACTION_IDS.FIND_PREVIOUS).run().then(null, onUnexpectedError); + }, + onKeyDown: (e) => { } + })); + + // Next button + this._nextBtn = this._register(new SimpleButton({ + label: NLS_NEXT_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.NextMatchFindAction), + className: 'codicon codicon-arrow-down', + onTrigger: () => { + this._notebookController.getAction(ACTION_IDS.FIND_NEXT).run().then(null, onUnexpectedError); + }, + onKeyDown: (e) => { } + })); + + let findPart = document.createElement('div'); + findPart.className = 'find-part'; + findPart.appendChild(this._findInput.domNode); + findPart.appendChild(this._matchesCount); + findPart.appendChild(this._prevBtn.domNode); + findPart.appendChild(this._nextBtn.domNode); + + // Close button + this._closeBtn = this._register(new SimpleButton({ + label: NLS_CLOSE_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.CloseFindWidgetCommand), + className: 'codicon codicon-close', + onTrigger: () => { + this._state.change({ isRevealed: false, searchScope: null }, false); + }, + onKeyDown: () => { } + })); + + findPart.appendChild(this._closeBtn.domNode); + + return findPart; + } + + private _buildDomNode(): void { + // Find part + let findPart = this._buildFindPart(); + + // Widget + this._domNode = document.createElement('div'); + this._domNode.className = 'editor-widget find-widget'; + this._domNode.setAttribute('aria-hidden', 'true'); + + this._domNode.appendChild(findPart); + + this._buildSash(); + } + + private _buildSash() { + this._resizeSash = new Sash(this._domNode, this, { orientation: Orientation.VERTICAL }); + let originalWidth = FIND_WIDGET_INITIAL_WIDTH; + + this._register(this._resizeSash.onDidStart((e: ISashEvent) => { + originalWidth = dom.getTotalWidth(this._domNode); + })); + + this._register(this._resizeSash.onDidChange((evt: ISashEvent) => { + let width = originalWidth + evt.startX - evt.currentX; + + if (width < FIND_WIDGET_INITIAL_WIDTH) { + // narrow down the find widget should be handled by CSS. + return; + } + + let maxWidth = parseFloat(dom.getComputedStyle(this._domNode).maxWidth) || 0; + if (width > maxWidth) { + return; + } + this._domNode.style.width = `${width}px`; + })); + } +} + + +interface ISimpleButtonOpts { + label: string; + className: string; + onTrigger: () => void; + onKeyDown: (e: IKeyboardEvent) => void; +} + +class SimpleButton extends Widget { + + private _opts: ISimpleButtonOpts; + private _domNode: HTMLElement; + + constructor(opts: ISimpleButtonOpts) { + super(); + this._opts = opts; + + this._domNode = document.createElement('div'); + this._domNode.title = this._opts.label; + this._domNode.tabIndex = 0; + this._domNode.className = 'button ' + this._opts.className; + this._domNode.setAttribute('role', 'button'); + this._domNode.setAttribute('aria-label', this._opts.label); + + this.onclick(this._domNode, (e) => { + this._opts.onTrigger(); + e.preventDefault(); + }); + this.onkeydown(this._domNode, (e) => { + if (e.equals(KeyCode.Space) || e.equals(KeyCode.Enter)) { + this._opts.onTrigger(); + e.preventDefault(); + return; + } + this._opts.onKeyDown(e); + }); + } + + public get domNode(): HTMLElement { + return this._domNode; + } + + public isEnabled(): boolean { + return (this._domNode.tabIndex >= 0); + } + + public focus(): void { + this._domNode.focus(); + } + + public setEnabled(enabled: boolean): void { + dom.toggleClass(this._domNode, 'disabled', !enabled); + this._domNode.setAttribute('aria-disabled', String(!enabled)); + this._domNode.tabIndex = enabled ? 0 : -1; + } + + public setExpanded(expanded: boolean): void { + this._domNode.setAttribute('aria-expanded', String(!!expanded)); + } + + public toggleClass(className: string, shouldHaveIt: boolean): void { + dom.toggleClass(this._domNode, className, shouldHaveIt); + } +} diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts index aa49ec59ae..bb39668ab6 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts @@ -114,6 +114,8 @@ suite('Notebook Editor Model', function (): void { executeEdits: undefined, getSections: undefined, navigateToSection: undefined, + cellEditors: undefined, + deltaDecorations: undefined, addCell: undefined }; }); diff --git a/src/sql/workbench/contrib/notebook/test/stubs.ts b/src/sql/workbench/contrib/notebook/test/stubs.ts index 40b1db72e7..f83413d694 100644 --- a/src/sql/workbench/contrib/notebook/test/stubs.ts +++ b/src/sql/workbench/contrib/notebook/test/stubs.ts @@ -4,14 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { nb, IConnectionProfile } from 'azdata'; - import * as vsEvent from 'vs/base/common/event'; -import { INotebookModel, ICellModel, IClientSession, NotebookContentChange, IKernelPreference } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; +import { INotebookModel, ICellModel, IClientSession, NotebookContentChange, IKernelPreference, INotebookFindModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; import { NotebookChangeType, CellType } from 'sql/workbench/contrib/notebook/common/models/contracts'; -import { INotebookManager, INotebookService, INotebookEditor, ILanguageMagic, INotebookProvider, INavigationProvider, INotebookParams, INotebookSection } from 'sql/workbench/services/notebook/browser/notebookService'; +import { INotebookManager, INotebookService, INotebookEditor, ILanguageMagic, INotebookProvider, INavigationProvider, INotebookParams, INotebookSection, ICellEditorProvider } from 'sql/workbench/services/notebook/browser/notebookService'; import { ISingleNotebookEditOperation } from 'sql/workbench/api/common/sqlExtHostTypes'; import { IStandardKernelWithProvider } from 'sql/workbench/contrib/notebook/browser/models/notebookUtils'; -import { URI, Emitter } from 'vs/workbench/workbench.web.api'; +import { IModelDecorationsChangeAccessor } from 'vs/editor/common/model'; +import { NotebookRange, NotebookFindMatch } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; +import { URI } from 'vs/workbench/workbench.web.api'; import { RenderMimeRegistry } from 'sql/workbench/contrib/notebook/browser/outputs/registry'; import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; @@ -120,6 +121,44 @@ export class NotebookModelStub implements INotebookModel { } } +export class NotebookFindModelStub implements INotebookFindModel { + + getFindCount(): number { + throw new Error('Method not implemented.'); + } + getFindIndex(): number { + throw new Error('Method not implemented.'); + } + findNext(): Promise { + throw new Error('Method not implemented.'); + } + findPrevious(): Promise { + throw new Error('Method not implemented.'); + } + find(exp: string, maxMatches?: number): Promise { + throw new Error('Method not implemented.'); + } + clearFind(): void { + throw new Error('Method not implemented.'); + } + findArray: NotebookRange[]; + getDecorationRange(id: string): NotebookRange { + throw new Error('Method not implemented.'); + } + changeDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => T, ownerId: number): T { + throw new Error('Method not implemented.'); + } + getLineMaxColumn(lineNumber: number): number { + throw new Error('Method not implemented.'); + } + getLineCount(): number { + throw new Error('Method not implemented.'); + } + findMatches: NotebookFindMatch[]; + findExpression: string; + onFindCountChange: vsEvent.Event; +} + export class NotebookManagerStub implements INotebookManager { providerId: string; contentManager: nb.ContentManager; @@ -128,7 +167,7 @@ export class NotebookManagerStub implements INotebookManager { } export class ServerManagerStub implements nb.ServerManager { - onServerStartedEmitter = new Emitter(); + onServerStartedEmitter = new vsEvent.Emitter(); onServerStarted: vsEvent.Event = this.onServerStartedEmitter.event; isStarted: boolean = false; calledStart: boolean = false; @@ -386,6 +425,10 @@ export class FutureStub implements nb.IFuture { } export class NotebookComponentStub implements INotebookEditor { + cellEditors: ICellEditorProvider[]; + deltaDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void { + throw new Error('Method not implemented.'); + } get notebookParams(): INotebookParams { throw new Error('Method not implemented.'); } diff --git a/src/sql/workbench/services/notebook/browser/notebookService.ts b/src/sql/workbench/services/notebook/browser/notebookService.ts index 20f1dde1b7..38d1a3cdad 100644 --- a/src/sql/workbench/services/notebook/browser/notebookService.ts +++ b/src/sql/workbench/services/notebook/browser/notebookService.ts @@ -16,6 +16,8 @@ import { ISingleNotebookEditOperation } from 'sql/workbench/api/common/sqlExtHos import { ICellModel, INotebookModel } from 'sql/workbench/contrib/notebook/browser/models/modelInterfaces'; import { NotebookChangeType, CellType } from 'sql/workbench/contrib/notebook/common/models/contracts'; import { IBootstrapParams } from 'sql/workbench/services/bootstrap/common/bootstrapParams'; +import { BaseTextEditor } from 'vs/workbench/browser/parts/editor/textEditor'; +import { NotebookRange } from 'sql/workbench/contrib/notebook/find/notebookFindDecorations'; export const SERVICE_ID = 'notebookService'; export const INotebookService = createDecorator(SERVICE_ID); @@ -143,10 +145,18 @@ export interface INotebookSection { relativeUri: string; } +export interface ICellEditorProvider { + hasEditor(): boolean; + cellGuid(): string; + getEditor(): BaseTextEditor; + deltaDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void; +} + export interface INotebookEditor { readonly notebookParams: INotebookParams; readonly id: string; readonly cells?: ICellModel[]; + readonly cellEditors: ICellEditorProvider[]; readonly modelReady: Promise; readonly model: INotebookModel | null; isDirty(): boolean; @@ -159,6 +169,7 @@ export interface INotebookEditor { clearAllOutputs(): Promise; getSections(): INotebookSection[]; navigateToSection(sectionId: string): void; + deltaDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void; addCell(cellType: CellType, index?: number, event?: Event); } diff --git a/src/sql/workbench/services/notebook/common/notebookContext.ts b/src/sql/workbench/services/notebook/common/notebookContext.ts index 4d5f2c5901..e9bff7683c 100644 --- a/src/sql/workbench/services/notebook/common/notebookContext.ts +++ b/src/sql/workbench/services/notebook/common/notebookContext.ts @@ -11,3 +11,8 @@ import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; export const notebookEditorVisibleId = 'notebookEditorVisible'; export const NotebookEditorVisibleContext = new RawContextKey(notebookEditorVisibleId, false); + +export const NOTEBOOK_COMMAND_SEARCH = 'notebook.command.search'; + +export const NOTEBOOK_COMMAND_CLOSE_SEARCH = 'notebook.command.closeSearch'; +