diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts index 25786552697..6c6088c73ae 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts @@ -34,6 +34,7 @@ import { NoteRename, NoteRevision, NoteRevisionForCompare, + NoteRevisionForCompareReceived, NoteRunningStatus, NoteUpdate, NoteUpdated, @@ -118,6 +119,7 @@ export interface MessageReceiveDataTypeMap { [OP.ANGULAR_OBJECT_UPDATE]: AngularObjectUpdate; [OP.ANGULAR_OBJECT_REMOVE]: AngularObjectRemove; [OP.PARAS_INFO]: ParasInfo; + [OP.NOTE_REVISION_FOR_COMPARE]: NoteRevisionForCompareReceived; } export interface MessageSendDataTypeMap { 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 a07d52d372b..986aed0b910 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 @@ -125,6 +125,13 @@ export interface NoteRevisionForCompare { position: string; } +export interface NoteRevisionForCompareReceived { + noteId: string; + revisionId: string; + position: string; + note: Note['note']; +} + export interface CollaborativeModeStatus { status: boolean; users: string[]; diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html index ed0bddd2f2c..8541afc964a 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html @@ -56,7 +56,11 @@ [(activatedExtension)]="activatedExtension" [permissions]="permissions" > - +
-
-
-

Revisions comparator

+
+
+
+ + + + Revision name + Date + + + + + {{ revision.message }} + {{ formatRevisionDate(revision.time) }} + + + +
+ +
+ + + + compare with + + + +
+ +
+
+
+
+ {{ p.paragraph.id }} + ({{ p.paragraph.title }}) + added + deleted + differences + identical + {{ p.firstString }} +
+
+
+ Please select a revision +
+
+
+
+ +
+ + Revision: + {{ currentFirstRevisionLabel }} --> {{ currentSecondRevisionLabel }} + +
{{
+      currentParagraphDiffDisplay?.paragraph?.text
+    }}
+
{{
+      currentParagraphDiffDisplay?.paragraph?.text
+    }}
+
{{ seg.text }}
+
+      
Nothing to display
+
- -
diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.less b/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.less index 019b5ca53b5..8eae3f0cc4b 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.less +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.less @@ -9,4 +9,150 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "theme-mixin"; +.themeMixin({ + .revisions-comparator { + padding: 10px 15px 15px; + } + + .commit-tree { + margin-bottom: 10px; + + ::ng-deep .ant-table-body { + overflow-y: auto !important; + } + } + + .cursor-hand { + cursor: pointer; + } + + .selected-revision { + background-color: fade(@primary-6, 15%) !important; + } + + .revisions-comparator-bar { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 12px; + flex-wrap: wrap; + + .revision-select { + flex: 1; + min-width: 100px; + display: block; + } + + .compare-label { + white-space: nowrap; + } + } + + .diff-panel { + border: 1px solid @border-color-base; + border-radius: 4px; + } + + .paragraphs-div { + overflow: auto; + max-height: 35vh; + } + + .paragraph-item { + transition: background-color 200ms ease-out; + border-bottom: 1px solid @border-color-split; + cursor: pointer; + + &:hover { + background-color: fade(@primary-6, 8%); + } + + &.paragraph-item-selected { + background-color: fade(@primary-6, 15%); + } + } + + .paragraph-item-heading { + padding: 8px 12px; + } + + .paragraph-id { + font-family: monospace; + font-size: 12px; + color: @text-color-secondary; + } + + .paragraph-title { + padding: 0 5px; + } + + .paragraph-first-string { + display: block; + height: 1.8em; + overflow: hidden; + padding-top: 4px; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; + color: @text-color-secondary; + } + + .empty-paragraph-message { + font-size: 1.5em; + color: @text-color-secondary; + text-align: center; + padding: 40px 0; + } + + .code-panel-col { + display: flex; + flex-direction: column; + } + + .code-panel-title { + font-size: 14px; + padding: 5px 0 8px; + } + + .code-panel { + flex: 1; + width: 100%; + min-height: 50vh; + max-height: 70vh; + overflow-y: auto; + border: 1px solid @border-color-base; + border-radius: 4px; + padding: 8px; + margin: 0; + font-size: 13px; + } + + .empty-code-panel { + text-align: center; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: @text-color-secondary; + } + + ::ng-deep { + .color-green-row { + background-color: fade(@green-6, 15%); + display: block; + color: @green-6; + } + + .color-red-row { + background-color: fade(@red-6, 15%); + display: block; + color: @red-6; + } + + .color-black { + color: inherit; + } + } +}); diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.ts index 1876b3cbbdb..3b45e77d44d 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component.ts @@ -10,16 +10,204 @@ * limitations under the License. */ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import * as DiffMatchPatch from 'diff-match-patch'; +import { Subscription } from 'rxjs'; + +import { NoteRevisionForCompareReceived, OP, ParagraphItem, RevisionListItem } from '@zeppelin/sdk'; +import { MessageService } from '@zeppelin/services'; + +interface DiffSegment { + type: 'insert' | 'delete' | 'equal'; + text: string; +} + +interface MergedParagraphDiff { + paragraph: ParagraphItem; + firstString: string; + type: 'added' | 'deleted' | 'compared'; + segments?: DiffSegment[]; + identical?: boolean; +} @Component({ selector: 'zeppelin-notebook-revisions-comparator', templateUrl: './revisions-comparator.component.html', styleUrls: ['./revisions-comparator.component.less'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DatePipe] }) -export class NotebookRevisionsComparatorComponent implements OnInit { - constructor() {} +export class NotebookRevisionsComparatorComponent implements OnInit, OnDestroy { + @Input() noteRevisions: RevisionListItem[] = []; + @Input() noteId!: string; + + firstNoteRevisionForCompare: NoteRevisionForCompareReceived | null = null; + secondNoteRevisionForCompare: NoteRevisionForCompareReceived | null = null; + currentFirstRevisionLabel = 'Choose...'; + currentSecondRevisionLabel = 'Choose...'; + mergeNoteRevisionsDiff: MergedParagraphDiff[] = []; + currentParagraphDiffDisplay: MergedParagraphDiff | null = null; + selectedFirstRevisionId: string | null = null; + selectedSecondRevisionId: string | null = null; + private subscription: Subscription | null = null; + private dmp = new DiffMatchPatch(); + + get sortedRevisions(): RevisionListItem[] { + return [...this.noteRevisions].sort((a, b) => (b.time || 0) - (a.time || 0)); + } + + constructor( + private messageService: MessageService, + private cdr: ChangeDetectorRef, + private datePipe: DatePipe + ) {} + + ngOnInit(): void { + this.subscription = this.messageService + .receive(OP.NOTE_REVISION_FOR_COMPARE) + .subscribe((data: NoteRevisionForCompareReceived) => { + if (data.note && data.position) { + if (data.position === 'first') { + this.firstNoteRevisionForCompare = data; + } else { + this.secondNoteRevisionForCompare = data; + } + + if ( + this.firstNoteRevisionForCompare !== null && + this.secondNoteRevisionForCompare !== null && + this.firstNoteRevisionForCompare.revisionId !== this.secondNoteRevisionForCompare.revisionId + ) { + this.compareRevisions(); + } + this.cdr.markForCheck(); + } + }); + } + + getNoteRevisionForReview(revision: RevisionListItem, position: 'first' | 'second'): void { + if (!revision) { + return; + } + if (position === 'first') { + this.currentFirstRevisionLabel = revision.message; + this.selectedFirstRevisionId = revision.id; + } else { + this.currentSecondRevisionLabel = revision.message; + this.selectedSecondRevisionId = revision.id; + } + this.messageService.noteRevisionForCompare(this.noteId, revision.id, position); + } + + onFirstRevisionSelect(revisionId: string): void { + const revision = this.noteRevisions.find(r => r.id === revisionId); + if (revision) { + this.getNoteRevisionForReview(revision, 'first'); + } + } + + onSecondRevisionSelect(revisionId: string): void { + const revision = this.noteRevisions.find(r => r.id === revisionId); + if (revision) { + this.getNoteRevisionForReview(revision, 'second'); + } + } + + onRevisionRowClick(index: number): void { + const sorted = this.sortedRevisions; + if (index < sorted.length - 1) { + this.getNoteRevisionForReview(sorted[index + 1], 'first'); + this.getNoteRevisionForReview(sorted[index], 'second'); + } + } + + compareRevisions(): void { + if (!this.firstNoteRevisionForCompare || !this.secondNoteRevisionForCompare) { + return; + } + const baseParagraphs = this.secondNoteRevisionForCompare.note?.paragraphs || []; + const compareParagraphs = this.firstNoteRevisionForCompare.note?.paragraphs || []; + const paragraphDiffs: MergedParagraphDiff[] = []; + + for (const p1 of baseParagraphs) { + const p2 = compareParagraphs.find((p: ParagraphItem) => p.id === p1.id) || null; + if (p2 === null) { + paragraphDiffs.push({ + paragraph: p1, + firstString: (p1.text || '').split('\n')[0], + type: 'added' + }); + } else { + const text1 = p1.text || ''; + const text2 = p2.text || ''; + const diffResult = this.buildLineDiff(text1, text2); + paragraphDiffs.push({ + paragraph: p1, + segments: diffResult.segments, + identical: diffResult.identical, + firstString: (p1.text || '').split('\n')[0], + type: 'compared' + }); + } + } + + for (const p2 of compareParagraphs) { + const p1 = baseParagraphs.find((p: ParagraphItem) => p.id === p2.id) || null; + if (p1 === null) { + paragraphDiffs.push({ + paragraph: p2, + firstString: (p2.text || '').split('\n')[0], + type: 'deleted' + }); + } + } + + this.mergeNoteRevisionsDiff = paragraphDiffs; + + if (this.currentParagraphDiffDisplay !== null) { + this.changeCurrentParagraphDiffDisplay(this.currentParagraphDiffDisplay.paragraph.id); + } + } + + changeCurrentParagraphDiffDisplay(paragraphId: string): void { + const found = this.mergeNoteRevisionsDiff.find(p => p.paragraph.id === paragraphId); + this.currentParagraphDiffDisplay = found || null; + } + + formatRevisionDate(time: number | undefined): string { + if (!time) { + return ''; + } + return this.datePipe.transform(time * 1000, 'MMMM d yyyy, h:mm:ss a') || ''; + } + + private buildLineDiff(text1: string, text2: string): { segments: DiffSegment[]; identical: boolean } { + const { chars1, chars2, lineArray } = this.dmp.diff_linesToChars_(text1, text2); + const diffs = this.dmp.diff_main(chars1, chars2, false); + this.dmp.diff_charsToLines_(diffs, lineArray); + + let identical = true; + const segments: DiffSegment[] = []; + + for (const [op, text] of diffs) { + if (op === DiffMatchPatch.DIFF_INSERT) { + segments.push({ type: 'insert', text }); + identical = false; + } else if (op === DiffMatchPatch.DIFF_DELETE) { + segments.push({ type: 'delete', text }); + identical = false; + } else { + segments.push({ type: 'equal', text }); + } + } + + return { segments, identical }; + } - ngOnInit() {} + ngOnDestroy(): void { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } } diff --git a/zeppelin-web-angular/src/styles/theme/dark-theme-overrides.css b/zeppelin-web-angular/src/styles/theme/dark-theme-overrides.css index 6b762b23152..df1106ec293 100644 --- a/zeppelin-web-angular/src/styles/theme/dark-theme-overrides.css +++ b/zeppelin-web-angular/src/styles/theme/dark-theme-overrides.css @@ -166,6 +166,21 @@ html.dark .ant-menu-submenu-title:hover { color: rgba(255, 255, 255, 0.95) !important; } +html.dark .ant-select-selector { + background-color: #262626 !important; + border-color: #434343 !important; + color: rgba(255, 255, 255, 0.85) !important; +} + +html.dark .ant-select-selector:hover, +html.dark .ant-select-selector:focus { + border-color: #177ddc !important; +} + +html.dark .ant-select-item-option-active:not(html.dark .ant-select-item-option-disabled) { + background-color: #262626 !important; +} + html.dark .ant-dropdown-menu-item-selected, html.dark .ant-dropdown-menu-submenu-title-selected, html.dark .ant-dropdown-menu-item-selected > a,