-
Revisions comparator
+
+
+
+
+
+
+ | Revision name |
+ Date |
+
+
+
+
+ | {{ revision.message }} |
+ {{ formatRevisionDate(revision.time) }} |
+
+
+
+
+
+
+ 0"
+ class="revision-select"
+ [ngModel]="selectedFirstRevisionId"
+ nzPlaceHolder="Choose..."
+ (ngModelChange)="onFirstRevisionSelect($event)"
+ >
+
+
+ compare with
+ 0"
+ class="revision-select"
+ [ngModel]="selectedSecondRevisionId"
+ [nzDisabled]="firstNoteRevisionForCompare === null"
+ nzPlaceHolder="Choose..."
+ (ngModelChange)="onSecondRevisionSelect($event)"
+ >
+
+
+
+
+
+
+
+
+ {{ 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,