diff --git a/zeppelin-web-angular/e2e/models/editor-search-page.ts b/zeppelin-web-angular/e2e/models/editor-search-page.ts index d47ba32fc47..45dd550a369 100644 --- a/zeppelin-web-angular/e2e/models/editor-search-page.ts +++ b/zeppelin-web-angular/e2e/models/editor-search-page.ts @@ -42,10 +42,22 @@ export class EditorSearchPage extends BasePage { this.matchesCount = this.findWidget.locator('.matchesCount').first(); // Monaco decorates every match with .findMatch and the active one with .currentFindMatch. this.matchHighlights = this.editor.locator('.findMatch, .currentFindMatch'); - this.nextMatchButton = this.findWidget.locator('.button.next, [title^="Next Match"]').first(); - this.previousMatchButton = this.findWidget.locator('.button.previous, [title^="Previous Match"]').first(); - this.toggleReplaceButton = this.findWidget.locator('.button.toggle, [title^="Toggle Replace"]').first(); - this.replaceAllButton = this.findWidget.locator('.button.replace-all, [title^="Replace All"]').first(); + this.nextMatchButton = this.findWidget + .locator('.button.next, .button.codicon-find-next-match, [aria-label^="Next Match"], [title^="Next Match"]') + .first(); + this.previousMatchButton = this.findWidget + .locator( + '.button.previous, .button.codicon-find-previous-match, [aria-label^="Previous Match"], [title^="Previous Match"]' + ) + .first(); + this.toggleReplaceButton = this.findWidget + .locator('.button.toggle, [aria-label^="Toggle Replace"], [title^="Toggle Replace"]') + .first(); + this.replaceAllButton = this.findWidget + .locator( + '.button.replace-all, .button.codicon-find-replace-all, [aria-label^="Replace All"], [title^="Replace All"]' + ) + .first(); } async openNotebook(noteId: string): Promise { diff --git a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts index ee2fa5f4812..a6c87814b10 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts @@ -461,18 +461,22 @@ test.describe.serial('Comprehensive Keyboard Shortcuts (ShortcutsMap)', () => { // ===== UI TOGGLE SHORTCUTS ===== test.describe('ParagraphActions.SwitchEditor: Control+Alt+E', () => { - test('should toggle editor visibility with Control+Alt+E', async () => { - // Given: A paragraph with visible editor - await keyboardPage.tryFocusCodeEditor(); - await keyboardPage.setCodeEditorContent('%python\nprint("Test editor toggle")'); + test('should toggle the focused editor with Control+Alt+E', async () => { + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.setCodeEditorContent('%python\nprint("First paragraph")', 0); + await keyboardPage.pressInsertBelow(); + await keyboardPage.waitForParagraphCountChange(2); + await keyboardPage.tryFocusCodeEditor(1); + await keyboardPage.setCodeEditorContent('%python\nprint("Second paragraph")', 1); + await keyboardPage.tryFocusCodeEditor(0); const initialEditorVisibility = await keyboardPage.isEditorVisible(0); + const secondEditorVisibility = await keyboardPage.isEditorVisible(1); - // When: User presses Control+Alt+E await keyboardPage.pressSwitchEditor(); - // Then: editor visibility toggles await expect.poll(() => keyboardPage.isEditorVisible(0), { timeout: 10000 }).toBe(!initialEditorVisibility); + expect(await keyboardPage.isEditorVisible(1)).toBe(secondEditorVisibility); }); }); diff --git a/zeppelin-web-angular/package-lock.json b/zeppelin-web-angular/package-lock.json index 544270d7377..af202a6fa17 100644 --- a/zeppelin-web-angular/package-lock.json +++ b/zeppelin-web-angular/package-lock.json @@ -32,7 +32,7 @@ "jquery-ui": "1.14.0", "lodash": "^4.17.21", "mathjax": "2.7.5", - "monaco-editor": "0.31.1", + "monaco-editor": "0.52.2", "ng-zorro-antd": "^21.3.0", "nvd3": "1.8.6", "parse5": "^5.1.1", @@ -75,7 +75,7 @@ "https-proxy-agent": "^2.2.1", "husky": "9.1.7", "lint-staged": "^15.5.2", - "monaco-editor-webpack-plugin": "7.0.1", + "monaco-editor-webpack-plugin": "7.1.1", "ng-packagr": "^21.2.3", "ngx-build-plus": "^20.0.0", "prettier": "^3.6.2", @@ -612,15 +612,15 @@ } }, "node_modules/@angular-builders/custom-webpack/node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@angular-builders/custom-webpack/node_modules/@vitejs/plugin-basic-ssl": { @@ -1217,15 +1217,15 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@angular-devkit/build-angular/node_modules/agent-base": { @@ -1642,23 +1642,6 @@ "typescript": "*" } }, - "node_modules/@angular/animations": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.15.tgz", - "integrity": "sha512-Z8AsLTwc++Fcu0fJnclAF9zMfumAd5KXrwtSdyECqLpqd+lEmmsOpeOl6P7loqdDz99KYh/8UF4eJxdMvnsaKw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/core": "21.2.15" - } - }, "node_modules/@angular/cdk": { "version": "21.2.13", "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.13.tgz", @@ -2082,15 +2065,15 @@ } }, "node_modules/@angular/cli/node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@angular/cli/node_modules/ansi-regex": { @@ -16303,15 +16286,15 @@ "license": "MIT" }, "node_modules/monaco-editor": { - "version": "0.31.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.31.1.tgz", - "integrity": "sha512-FYPwxGZAeP6mRRyrr5XTGHD9gRXVjy7GUzF4IPChnyt3fS5WrNxIkS8DNujWf6EQy0Zlzpxw8oTVE+mWI2/D1Q==", + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", "license": "MIT" }, "node_modules/monaco-editor-webpack-plugin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.0.1.tgz", - "integrity": "sha512-M8qIqizltrPlIbrb73cZdTWfU9sIsUVFvAZkL3KGjAHmVWEJ0hZKa/uad14JuOckc0GwnCaoGHvMoYtJjVyCzw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.1.tgz", + "integrity": "sha512-WxdbFHS3Wtz4V9hzhe/Xog5hQRSMxmDLkEEYZwqMDHgJlkZo00HVFZR0j5d0nKypjTUkkygH3dDSXERLG4757A==", "dev": true, "license": "MIT", "dependencies": { @@ -20520,9 +20503,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT", "optional": true, diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index 756dab31275..11ceee4c595 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -59,7 +59,7 @@ "jquery-ui": "1.14.0", "lodash": "^4.17.21", "mathjax": "2.7.5", - "monaco-editor": "0.31.1", + "monaco-editor": "0.52.2", "ng-zorro-antd": "^21.3.0", "nvd3": "1.8.6", "parse5": "^5.1.1", @@ -102,7 +102,7 @@ "https-proxy-agent": "^2.2.1", "husky": "9.1.7", "lint-staged": "^15.5.2", - "monaco-editor-webpack-plugin": "7.0.1", + "monaco-editor-webpack-plugin": "7.1.1", "ng-packagr": "^21.2.3", "ngx-build-plus": "^20.0.0", "prettier": "^3.6.2", diff --git a/zeppelin-web-angular/src/app/key-binding/key-binder.ts b/zeppelin-web-angular/src/app/key-binding/key-binder.ts index 5267c78849a..cfbec5b50b5 100644 --- a/zeppelin-web-angular/src/app/key-binding/key-binder.ts +++ b/zeppelin-web-angular/src/app/key-binding/key-binder.ts @@ -18,14 +18,18 @@ import { map, mergeMap, takeUntil } from 'rxjs/operators'; import { ShortcutService } from '@zeppelin/services'; import { castArray, chain, isNil } from 'lodash'; import { KeyCodeConverter } from './key-code-converter'; +import { MonacoHandledParagraphActions } from './notebook-paragraph-keyboard-event-handler'; import { ParagraphActions } from './paragraph-actions'; import { ShortcutsMap } from './shortcuts-map'; export class KeyBinder { + private static nextMonacoContextId = 0; + private events$ = new Subject<{ action: ParagraphActions; event: KeyboardEvent | null; }>(); + private readonly monacoContext = `zeppelin.paragraphEditor.${KeyBinder.nextMonacoContextId++}`; constructor( private destroySubject: Observable, @@ -56,17 +60,23 @@ export class KeyBinder { } initKeyBindingsOnMonaco(editor: MonacoEditor.IStandaloneCodeEditor) { + editor.createContextKey(this.monacoContext, true); chain(ShortcutsMap) .toPairs() + .filter(([action]) => MonacoHandledParagraphActions.some(monacoAction => monacoAction === action)) .flatMap(([action, keys]) => castArray(keys).map(key => ({ action, key }))) .forEach(({ action, key }) => { const keyBinding = KeyCodeConverter.angularToMonacoKeyBinding(key); if (isNil(keyBinding)) { return; } - editor.addCommand(keyBinding, () => { - this.events$.next({ action: action as ParagraphActions, event: null }); - }); + editor.addCommand( + keyBinding, + () => { + this.events$.next({ action: action as ParagraphActions, event: null }); + }, + this.monacoContext + ); }) .value(); } diff --git a/zeppelin-web-angular/src/app/key-binding/notebook-paragraph-keyboard-event-handler.ts b/zeppelin-web-angular/src/app/key-binding/notebook-paragraph-keyboard-event-handler.ts index 3c6e8508c83..95cd4ec80fe 100644 --- a/zeppelin-web-angular/src/app/key-binding/notebook-paragraph-keyboard-event-handler.ts +++ b/zeppelin-web-angular/src/app/key-binding/notebook-paragraph-keyboard-event-handler.ts @@ -71,9 +71,7 @@ export const ParagraphActionToHandlerName = { [ParagraphActions.FindInCode]: 'handleFindInCode' } as const satisfies Record; -// Referenced only via `typeof` below to derive a type; the runtime binding is intentionally unused. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const MonacoHandledParagraphActions = [ +export const MonacoHandledParagraphActions = [ ParagraphActions.MoveCursorUp, ParagraphActions.MoveCursorDown, ParagraphActions.SwitchEditor, diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts index 093a34e11cc..d6b183262cf 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts @@ -26,7 +26,7 @@ import { import { editor as MonacoEditor, IDisposable, IPosition, KeyCode } from 'monaco-editor'; import { InterpreterBindingItem } from '@zeppelin/sdk'; -import { CompletionService, MessageService } from '@zeppelin/services'; +import { CompletionService, InlineCompletionService, MessageService } from '@zeppelin/services'; import { MonacoKeyboardEventHandler, ParagraphActions, ParagraphActionToHandlerName } from '@zeppelin/key-binding'; import { pt2px } from '@zeppelin/utility'; @@ -160,7 +160,7 @@ export class NotebookParagraphCodeEditorComponent handleShowFind() { if (this.editor) { - this.editor.getAction('actions.find').run(); + this.editor.getAction('actions.find')?.run(); // Focus on the find widget input field const findInput = document.querySelector('.find-widget .input') as HTMLInputElement; @@ -216,6 +216,7 @@ export class NotebookParagraphCodeEditorComponent this.initEditorListener(this.editor); this.initEditorFocus(); this.initCompletionService(this.editor); + this.initInlineCompletionService(this.editor); this.setEditorValue(this.editor); setTimeout(() => { this.autoAdjustEditorHeight(); @@ -280,6 +281,14 @@ export class NotebookParagraphCodeEditorComponent this.completionService.registerAsCompletionReceiver(model, this.paragraphControl.pid); } + initInlineCompletionService(editor: IStandaloneCodeEditor): void { + const model = editor.getModel(); + if (!model) { + return; + } + this.inlineCompletionService.register(model, this.paragraphControl.pid); + } + initEditorFocus() { if (this.focus && this.editor) { this.editor.focus(); @@ -299,6 +308,7 @@ export class NotebookParagraphCodeEditorComponent contextmenu: false, matchBrackets: 'always', wordWrap: 'on', + inlineSuggest: { enabled: true }, scrollbar: { handleMouseWheel: false, alwaysConsumeMouseWheel: false @@ -390,7 +400,8 @@ export class NotebookParagraphCodeEditorComponent private cdr: ChangeDetectorRef, private ngZone: NgZone, private messageService: MessageService, - private completionService: CompletionService + private completionService: CompletionService, + private inlineCompletionService: InlineCompletionService ) {} ngOnChanges(changes: SimpleChanges): void { @@ -421,6 +432,7 @@ export class NotebookParagraphCodeEditorComponent const model = this.editor?.getModel(); if (model) { this.completionService.unregister(model); + this.inlineCompletionService.unregister(model); } this.monacoDisposables.forEach(d => d.dispose()); } diff --git a/zeppelin-web-angular/src/app/services/inline-completion.service.ts b/zeppelin-web-angular/src/app/services/inline-completion.service.ts new file mode 100644 index 00000000000..62f92b4ded2 --- /dev/null +++ b/zeppelin-web-angular/src/app/services/inline-completion.service.ts @@ -0,0 +1,203 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { editor, IDisposable, IRange, languages, Position } from 'monaco-editor'; + +const MIN_PREFIX_LENGTH = 3; +const LOCAL_WINDOW_LINES = 400; +const MAX_CROSS_SCAN_LINES = 4000; + +interface Registration { + // Unused by the history tier; kept as the paragraph id the future LLM tier sends to the server. + pid: string; + sub: IDisposable; +} + +/** + * Ghost-text (inline) completions for the notebook editor. + * + * `suggest()` resolves a suggestion through tiers: + * a future server-backed LLM tier falls back to the history tier below, + * which repeats a line already written in the note. + * + * Opt-in via `?aiInlineComplete=true`; off by default. + */ +@Injectable({ providedIn: 'root' }) +export class InlineCompletionService { + private readonly languagesToRegister = ['python', 'scala']; + // Plain Map (not WeakMap) so the history tier can scan every open paragraph. + // Models self-remove on dispose, so they are never pinned in memory. + private readonly models = new Map(); + private bound = false; + + register(model: editor.ITextModel, pid: string): void { + if (!this.isEnabled() || this.models.has(model)) { + return; + } + if (!this.bound) { + this.bindProvider(); + this.bound = true; + } + const sub = model.onWillDispose(() => this.unregister(model)); + this.models.set(model, { pid, sub }); + } + + unregister(model: editor.ITextModel): void { + const entry = this.models.get(model); + if (entry) { + entry.sub.dispose(); + this.models.delete(model); + } + } + + private isEnabled(): boolean { + try { + return new URLSearchParams(window.location.search).get('aiInlineComplete') === 'true'; + } catch { + return false; + } + } + + // Registered once for the app lifetime; the provider disposable is intentionally dropped. + private bindProvider(): void { + this.languagesToRegister.forEach(language => { + languages.registerInlineCompletionsProvider(language, { + provideInlineCompletions: (model: editor.ITextModel, position: Position): languages.InlineCompletions => { + // The provider is global per language; only enabled, registered models get suggestions. + if (!this.isEnabled() || !this.models.has(model)) { + return { items: [] }; + } + const insertText = this.suggest(model, position); + if (!insertText) { + return { items: [] }; + } + const range: IRange = { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column + }; + return { items: [{ insertText, range }] }; + }, + freeInlineCompletions: (): void => {} + }); + }); + } + + /** Seam for tiers. The LLM tier slots in above history in a follow-up; today only history runs. */ + private suggest(model: editor.ITextModel, position: Position): string | null { + return this.historySuggestion(model, position); + } + + /** + * If a line elsewhere starts with the current line's text before the cursor, + * offer the rest as ghost text: + * the current cell first (nearest match), then other paragraphs. + * Scans are line-budgeted so the per-keystroke cost stays bounded. Pure client-side. + */ + private historySuggestion(model: editor.ITextModel, position: Position): string | null { + // Only complete at end of line; otherwise the remainder would corrupt the existing suffix. + const afterCursor = model.getLineContent(position.lineNumber).slice(position.column - 1); + if (afterCursor.trim().length > 0) { + return null; + } + + const linePrefix = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column + }); + if (linePrefix.trim().length < MIN_PREFIX_LENGTH) { + return null; + } + + // Current cell: a window around the cursor, nearest match wins. + const cursorIndex = position.lineNumber - 1; + const local = this.matchInModel( + model, + linePrefix, + cursorIndex, + Math.max(0, cursorIndex - LOCAL_WINDOW_LINES), + cursorIndex + LOCAL_WINDOW_LINES + 1 + ); + if (local !== null) { + return local; + } + + // Other paragraphs, same language, sharing one line budget so total cross-paragraph work per + // keystroke stays bounded (a huge paragraph consumes the budget rather than blowing it up). + let budget = MAX_CROSS_SCAN_LINES; + for (const other of this.models.keys()) { + if (other === model) { + continue; + } + if (other.isDisposed()) { + this.unregister(other); + continue; + } + // Language checked here, not at register time, since a paragraph's language can change. + if (other.getLanguageId() !== model.getLanguageId()) { + continue; + } + if (budget <= 0) { + break; + } + const remainder = this.matchInModel(other, linePrefix, -1, 0, budget); + budget -= Math.min(other.getLineCount(), budget); + if (remainder !== null) { + return remainder; + } + } + return null; + } + + /** + * Remainder of the line nearest `skipIndex` starting with `linePrefix`, within `[startLine, endLine)`. + * Ties go to the earlier line; `skipIndex = -1` returns the topmost match. + * Trailing whitespace is stripped and whitespace-only matches skipped. + */ + private matchInModel( + model: editor.ITextModel, + linePrefix: string, + skipIndex: number, + startLine: number, + endLine: number + ): string | null { + const limit = Math.min(endLine, model.getLineCount()); + let remainder: string | null = null; + let bestDistance = Infinity; + for (let i = Math.max(0, startLine); i < limit; i++) { + if (i === skipIndex) { + continue; + } + const line = model.getLineContent(i + 1); + if (!line.startsWith(linePrefix)) { + continue; + } + // trimEnd() is ES2019+ and this tsconfig's lib is es2018, so use a regex. + const candidate = line.slice(linePrefix.length).replace(/\s+$/, ''); + if (candidate.length > 0) { + if (skipIndex < 0) { + return candidate; + } + const distance = Math.abs(i - skipIndex); + if (distance < bestDistance) { + bestDistance = distance; + remainder = candidate; + } + } + } + return remainder; + } +} diff --git a/zeppelin-web-angular/src/app/services/public-api.ts b/zeppelin-web-angular/src/app/services/public-api.ts index a554dc9cf43..7ba64de53b7 100644 --- a/zeppelin-web-angular/src/app/services/public-api.ts +++ b/zeppelin-web-angular/src/app/services/public-api.ts @@ -18,6 +18,7 @@ export * from './completion.service'; export * from './configuration.service'; export * from './credential.service'; export * from './helium.service'; +export * from './inline-completion.service'; export * from './interpreter.service'; export * from './job-manager.service'; export * from './message.service';