diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java index 20343d8a0a5..090272ce5d9 100644 --- a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java +++ b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java @@ -692,16 +692,17 @@ private void broadcastParagraphs(Map userParagraphMap, Paragr inlineBroadcastParagraphs(userParagraphMap, msgId); } - private void inlineBroadcastNewParagraph(Note note, Paragraph para) { + private void inlineBroadcastNewParagraph(Note note, Paragraph para, String msgId) { LOGGER.info("Broadcasting paragraph on run call instead of note."); int paraIndex = note.getParagraphs().indexOf(para); - Message message = new Message(OP.PARAGRAPH_ADDED).put("paragraph", para).put("index", paraIndex); + Message message = + new Message(OP.PARAGRAPH_ADDED).withMsgId(msgId).put("paragraph", para).put("index", paraIndex); connectionManager.broadcast(note.getId(), message); } - private void broadcastNewParagraph(Note note, Paragraph para) { - inlineBroadcastNewParagraph(note, para); + private void broadcastNewParagraph(Note note, Paragraph para, String msgId) { + inlineBroadcastNewParagraph(note, para, msgId); } private void inlineBroadcastNoteList() { @@ -1451,7 +1452,7 @@ private String insertParagraph(NotebookSocket conn, @Override public void onSuccess(Paragraph p, ServiceContext context) throws IOException { super.onSuccess(p, context); - broadcastNewParagraph(p.getNote(), p); + broadcastNewParagraph(p.getNote(), p, fromMessage.msgId); } }); @@ -1555,7 +1556,7 @@ public void onSuccess(Paragraph p, ServiceContext context) StringUtils.isEmpty(p.getScriptText())) && isTheLastParagraph) { Paragraph newPara = p.getNote().addNewParagraph(p.getAuthenticationInfo()); - broadcastNewParagraph(p.getNote(), newPara); + broadcastNewParagraph(p.getNote(), newPara, fromMessage.msgId); } } }); diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts index 986aed0b910..665e8dfd71f 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts @@ -167,6 +167,7 @@ export interface ImportNoteReceived { export interface ParagraphAdded { index: number; + msgId?: string; paragraph: ParagraphItem; } diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts index 6905a5fc4e5..1cb8d2b288b 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts @@ -157,6 +157,17 @@ export class NotebookComponent extends MessageListenersManager implements OnInit definedNote.paragraphs[paragraphIndex].focus = true; this.cdr.markForCheck(); + + // Focus the editor only for a clone/insert initiated by this client (not auto-append on run or remote inserts). + // Defer a tick so the new paragraph's editor child exists, since `focus = true` alone misses it. + if (this.messageService.consumeLocalAddFocusMsgId(data.msgId)) { + const addedId = data.paragraph.id; + setTimeout(() => { + const added = this.listOfNotebookParagraphComponent?.find(e => e.paragraph.id === addedId); + added?.focusEditor(); + added?.notebookParagraphCodeEditorComponent?.setRestorePosition(); + }); + } } @MessageListener(OP.SAVE_NOTE_FORMS) 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 c212de77cf7..093a34e11cc 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 @@ -94,19 +94,24 @@ export class NotebookParagraphCodeEditorComponent this.position = e.position; }); }), - editor.onDidChangeModelContent(() => { + editor.onDidChangeModelContent(e => { this.ngZone.run(() => { const model = editor.getModel(); if (!model) { throw new Error('Model content changed but model not found.'); } this.text = model.getValue(); - this.textChanged.emit(this.text); - this.setParagraphMode(true); this.autoAdjustEditorHeight(); setTimeout(() => { this.autoAdjustEditorHeight(); }); + this.setParagraphMode(true); + // A flush is a programmatic setValue (editor init, remote content update, patch), not a user edit. + // Such changes must not mark the paragraph dirty. + if (e.isFlush) { + return; + } + this.textChanged.emit(this.text); }); }) ); diff --git a/zeppelin-web-angular/src/app/services/message.service.ts b/zeppelin-web-angular/src/app/services/message.service.ts index 8949e5e1c79..9b86a7d42d6 100644 --- a/zeppelin-web-angular/src/app/services/message.service.ts +++ b/zeppelin-web-angular/src/app/services/message.service.ts @@ -12,6 +12,7 @@ import { Inject, Injectable, OnDestroy, Optional } from '@angular/core'; import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { MessageInterceptor, MESSAGE_INTERCEPTOR } from '@zeppelin/interfaces'; import { @@ -22,6 +23,7 @@ import { MessageSendDataTypeMap, Note, NoteConfig, + OP, ParagraphConfig, ParagraphParams, PersonalizedMode, @@ -38,6 +40,8 @@ import { TicketService } from './ticket.service'; providedIn: 'root' }) export class MessageService extends Message implements OnDestroy { + private readonly localAddFocusMsgIds = new Set(); + constructor( private baseUrlService: BaseUrlService, private ticketService: TicketService, @@ -47,7 +51,11 @@ export class MessageService extends Message implements OnDestroy { } interceptReceived(data: WebSocketMessage): WebSocketMessage { - return this.messageInterceptor ? this.messageInterceptor.received(data) : super.interceptReceived(data); + const received = this.messageInterceptor ? this.messageInterceptor.received(data) : super.interceptReceived(data); + if (received.op === OP.PARAGRAPH_ADDED && received.data && received.msgId) { + (received.data as MessageReceiveDataTypeMap[OP.PARAGRAPH_ADDED]).msgId = received.msgId; + } + return received; } bootstrap(): void { @@ -78,6 +86,30 @@ export class MessageService extends Message implements OnDestroy { return super.receive(op); } + consumeLocalAddFocusMsgId(msgId: string | undefined): boolean { + if (!msgId) { + return false; + } + return this.localAddFocusMsgIds.delete(msgId); + } + + private captureLocalAddFocusMsgId(sendMessage: () => void): void { + const subscription = super + .sent() + .pipe(take(1)) + .subscribe(message => { + if (message.msgId) { + this.localAddFocusMsgIds.add(message.msgId); + } + }); + try { + sendMessage(); + } catch (error) { + subscription.unsubscribe(); + throw error; + } + } + opened(): Observable { return super.opened(); } @@ -167,7 +199,7 @@ export class MessageService extends Message implements OnDestroy { } insertParagraph(newIndex: number): void { - super.insertParagraph(newIndex); + this.captureLocalAddFocusMsgId(() => super.insertParagraph(newIndex)); } copyParagraph( @@ -177,7 +209,9 @@ export class MessageService extends Message implements OnDestroy { paragraphConfig: ParagraphConfig, paragraphParams: ParagraphParams ): void { - super.copyParagraph(newIndex, paragraphTitle, paragraphData, paragraphConfig, paragraphParams); + this.captureLocalAddFocusMsgId(() => + super.copyParagraph(newIndex, paragraphTitle, paragraphData, paragraphConfig, paragraphParams) + ); } angularObjectUpdate(