Repository: jupyter/nbdime (language: python)

Issue:
Support merge view with 3 panels (theirs | final | ours) in addition to the current 4 panels views
In `@jupyterlab/git`, the merge addon from CM5 was used directly to display merge views for plain text using a 3 panels view: `theirs | final | ours`. It will be nice to add a setting in nbdime widget to select either the current 4 panels view or that 3 panel view.


Discussion / hints:

Fix patch:
diff --git a/nbdime/args.py b/nbdime/args.py
index 5654746e..93108668 100644
--- a/nbdime/args.py
+++ b/nbdime/args.py
@@ -465,6 +465,12 @@ def add_merge_args(parser):
         default=True,
         help="disallow deletion of transient data such as outputs and "
              "execution counts in order to resolve conflicts.")
+    parser.add_argument(
+        '--no-base',
+        dest='show_base',
+        action="store_false",
+        default=True,
+        help="Don't display the base version.")
 
 
 filename_help = {
@@ -532,6 +538,7 @@ def args_for_server(arguments):
                 workdirectory='cwd',
                 base_url='base_url',
                 hide_unchanged='hide_unchanged',
+                show_base='show_base',
                 )
     ret = {kmap[k]: v for k, v in vars(arguments).items() if k in kmap}
     if 'persist' in arguments:
diff --git a/nbdime/config.py b/nbdime/config.py
index 9768b3ad..b318d05b 100644
--- a/nbdime/config.py
+++ b/nbdime/config.py
@@ -256,6 +256,11 @@ class Merge(_Diffing):
              "execution counts in order to resolve conflicts.",
     ).tag(config=True)
 
+    show_base = Bool(
+        True,
+        help="Whether to show the base version (4-panels) or not (3-panels).",
+    ).tag(config=True)
+
 
 class GitDiff(Diff):
     use_filter = Bool(
diff --git a/nbdime/webapp/nbdimeserver.py b/nbdime/webapp/nbdimeserver.py
index 9e722f34..f6c157c7 100644
--- a/nbdime/webapp/nbdimeserver.py
+++ b/nbdime/webapp/nbdimeserver.py
@@ -189,6 +189,7 @@ def get(self):
         args['base'] = self.get_argument('base', '')
         args['local'] = self.get_argument('local', '')
         args['remote'] = self.get_argument('remote', '')
+        args['showBase'] = self.params.get('show_base', True)
         self.write(self.render_template('merge.html',
                     config_data=args,
                    ))
@@ -201,10 +202,12 @@ def get(self):
             args['base'] = self.params['mergetool_args']['base']
             args['local'] = self.params['mergetool_args']['local']
             args['remote'] = self.params['mergetool_args']['remote']
+            args['showBase'] = self.params.get('show_base', True)
         else:
             args['base'] = self.get_argument('base', '')
             args['local'] = self.get_argument('local', '')
             args['remote'] = self.get_argument('remote', '')
+            args['showBase'] = self.params.get('show_base', True)
         self.write(self.render_template('mergetool.html',
                     config_data=args,
                    ))
diff --git a/packages/nbdime/src/common/basepanel.ts b/packages/nbdime/src/common/basepanel.ts
new file mode 100644
index 00000000..17a8566f
--- /dev/null
+++ b/packages/nbdime/src/common/basepanel.ts
@@ -0,0 +1,36 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { Panel } from '@lumino/widgets';
+import type { IDiffWidgetOptions, IMergeWidgetOptions } from './interfaces';
+import type { CodeEditor } from '@jupyterlab/codeeditor';
+
+/**
+ * Common panel for diff views
+ */
+export class DiffPanel<T> extends Panel {
+  constructor({ model, editorFactory }: IDiffWidgetOptions<T>) {
+    super();
+    this._editorFactory = editorFactory;
+    this._model = model;
+  }
+
+  protected _editorFactory: CodeEditor.Factory | undefined;
+  protected _model: T;
+}
+
+/**
+ * Common panel for merge views
+ */
+export class MergePanel<T> extends DiffPanel<T> {
+  constructor({
+    model,
+    editorFactory,
+    ...viewOptions
+  }: IDiffWidgetOptions<T> & IMergeWidgetOptions) {
+    super({ model, editorFactory });
+    this._viewOptions = viewOptions;
+  }
+
+  protected _viewOptions: IMergeWidgetOptions;
+}
diff --git a/packages/nbdime/src/common/interfaces.ts b/packages/nbdime/src/common/interfaces.ts
index f09cde42..7053bcd3 100644
--- a/packages/nbdime/src/common/interfaces.ts
+++ b/packages/nbdime/src/common/interfaces.ts
@@ -1,18 +1,33 @@
 import type { CodeEditor } from '@jupyterlab/codeeditor';
 import type { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
+/**
+ * Common merge view options
+ */
+export interface IMergeWidgetOptions {
+  /**
+   * Whether to show the base version (4-panels) or not (3-panels).
+   */
+  showBase?: boolean;
+}
+
 /**
  * Main widget constructor options
  */
 // TODO `T` should be scoped down but more API rework will be needed on the model to achieve that
 // there is definitely room to rationalize the code with more abstract or mixin classes.
 export interface IDiffWidgetOptions<T> {
+  model: T;
+  editorFactory?: CodeEditor.Factory;
+}
+
+export interface IMimeDiffWidgetOptions<T> {
   model: T;
   rendermime: IRenderMimeRegistry;
   editorFactory?: CodeEditor.Factory;
 }
 
-export interface ICellDiffWidgetOptions<T> extends IDiffWidgetOptions<T> {
+export interface ICellDiffWidgetOptions<T> extends IMimeDiffWidgetOptions<T> {
   // TODO this seems redundant as mimetype is part of the model
   mimetype: string;
 }
diff --git a/packages/nbdime/src/common/mergeview.ts b/packages/nbdime/src/common/mergeview.ts
index 55bed148..076be78a 100644
--- a/packages/nbdime/src/common/mergeview.ts
+++ b/packages/nbdime/src/common/mergeview.ts
@@ -44,6 +44,8 @@ import {
   createEditorFactory,
 } from './editor';
 
+import type { IMergeWidgetOptions } from './interfaces';
+
 import { valueIn, hasEntries, splitLines } from './util';
 
 const PICKER_SYMBOL = '\u27ad';
@@ -510,11 +512,26 @@ const CollapsedRangesField = StateField.define<DecorationSet>({
   provide: f => EditorView.decorations.from(f),
 });
 
-export interface IMergeViewOptions {
+/**
+ * Merge view factory options
+ */
+export interface IMergeViewOptions extends Partial<IMergeWidgetOptions> {
+  /**
+   * Diff between the reference and a remote version
+   */
   remote: IStringDiffModel | null;
+  /**
+   * Diff between the reference and a local version
+   */
   local?: IStringDiffModel | null;
+  /**
+   * Diff between the reference and the merged version
+   */
   merged?: IStringDiffModel;
   readOnly?: boolean | string;
+  /**
+   * Text editor factory
+   */
   factory?: CodeEditor.Factory;
 }
 
@@ -522,13 +539,14 @@ export interface IMergeViewOptions {
  * A wrapper view for showing StringDiffModels in a MergeView
  */
 export function createNbdimeMergeView(options: IMergeViewOptions): MergeView {
-  const { remote, local, merged, readOnly, factory } = options;
+  const { remote, local, merged, readOnly, factory, showBase } = options;
   let opts: IMergeViewEditorConfiguration = {
     remote,
     local,
     merged,
     config: { readOnly },
     factory: factory ?? createEditorFactory(),
+    showBase,
   };
 
   let mergeview = new MergeView(opts);
@@ -554,37 +572,59 @@ export function createNbdimeMergeView(options: IMergeViewOptions): MergeView {
   return mergeview;
 }
 
+/**
+ * DiffView options
+ */
+export interface IDiffViewOptions {
+  /**
+   * Diff model
+   */
+  model: IStringDiffModel;
+  /**
+   * Remote content type
+   */
+  type: 'left' | 'right' | 'merge';
+  /**
+   * Editor configuration
+   */
+  options: Omit<IEditorWidgetOptions, 'value'>;
+  /**
+   * Whether to synchronize scrolling between the two editors or not.
+   */
+  lockScroll?: boolean;
+}
+
 /**
  * Used by MergeView to show diff in a string diff model
  */
 export class DiffView {
-  constructor(
-    model: IStringDiffModel,
-    type: 'left' | 'right' | 'merge',
-    options: Omit<IEditorWidgetOptions, 'value'>,
-  ) {
+  constructor({ model, type, options, lockScroll }: IDiffViewOptions) {
     this._model = model;
     this._type = type;
+    this._lockScroll = lockScroll ?? true;
     let remoteValue = this._model.remote || '';
     this._remoteEditorWidget = new EditorWidget({
       ...options,
       value: remoteValue,
     });
+    this.setScrollLock(this._lockScroll);
   }
 
   init(baseWidget: EditorWidget) {
     this._baseEditorWidget = baseWidget;
-    let baseEditor = this._baseEditorWidget.cm;
-    let remoteEditor = this._remoteEditorWidget.cm;
     this._lineChunks = this._model.getLineChunks();
     this._chunks = lineToNormalChunks(this._lineChunks);
-    this.updateView(baseEditor, remoteEditor);
-    this.syncScroll(baseEditor, remoteEditor);
+    this.updateView();
+    this.syncScroll();
   }
+
   /**
-   Update the highlighting in the views of baseEditor and remoteEditor
+   * Update the highlighting in the views of baseEditor and remoteEditor
    */
-  updateView(baseEditor: EditorView, remoteEditor: EditorView) {
+  updateView() {
+    const baseEditor = this._baseEditorWidget.cm;
+    const remoteEditor = this._remoteEditorWidget.cm;
+
     this.clearHighlighting(
       remoteEditor,
       this._model.additions,
@@ -614,7 +654,7 @@ export class DiffView {
     );
   }
   /**
-  Update the chunks once a version has been picked
+   * Update the chunks once a version has been picked
    */
   syncModel() {
     if (!this.modelInvalid()) {
@@ -644,6 +684,7 @@ export class DiffView {
     this._lineChunks = updatedLineChunks;
     this._chunks = updatedChunks;
   }
+
   /**
    * Add a gap DOM element between 2 editors
    */
@@ -663,6 +704,11 @@ export class DiffView {
     return this._gap;
   }
 
+  /**
+   * Whether to lock scroll or not.
+   *
+   * @param val Scroll lock value
+   */
   setScrollLock(val: boolean) {
     this._lockScroll = val;
     if (this._lockButton) {
@@ -690,12 +736,13 @@ export class DiffView {
    * srcEditor refers to the source editor from which the scrolling is done and listened
    * destEditor is the destination editor whose scrolling is synchronized with the one of srcEditor.
    */
-  private syncScroll(srcEditor: EditorView, destEditor: EditorView): void {
+  private syncScroll(): void {
     if (this.modelInvalid()) {
       return;
     }
-    let srcScroller = srcEditor.scrollDOM;
-    let destScroller = destEditor.scrollDOM;
+
+    const srcScroller = this._baseEditorWidget.cm.scrollDOM;
+    const destScroller = this._remoteEditorWidget.cm.scrollDOM;
     srcScroller.addEventListener(
       'scroll',
       event => {
@@ -1091,7 +1138,7 @@ export class DiffView {
   private _chunks: Chunk[];
   private _lineChunks: Chunk[];
   private _gap: HTMLElement;
-  private _lockScroll = true;
+  private _lockScroll: boolean;
   private _lockButton: HTMLElement;
 }
 
@@ -1390,14 +1437,8 @@ export class MergeView extends Panel {
 
     // START MERGE CASE
     if (merged) {
-      this._gridPanel = new Panel();
-      this.addWidget(this._gridPanel);
-      this._gridPanel.addClass('cm-merge-grid-panel');
-      let showBase = options.showBase !== false;
-
-      if (!showBase) {
-        this._base.node.style.display = 'hidden';
-      }
+      this.addClass('cm-merge-grid-panel');
+      const showBase = (this._showBase = options.showBase !== false);
 
       let leftWidget: Widget;
       if (!local || local.remote === null) {
@@ -1407,19 +1448,24 @@ export class MergeView extends Panel {
           node: elt('div', 'Value missing', 'jp-mod-missing'),
         });
       } else {
-        left = this._left = new DiffView(local, 'left', {
-          ...options,
-          // Copy configuration
-          config: { ...options.config },
-          extensions: [options.extensions ?? [], additionalExtensions],
+        left = this._left = new DiffView({
+          model: local,
+          type: 'left',
+          options: {
+            ...options,
+            // Copy configuration
+            config: { ...options.config },
+            extensions: [options.extensions ?? [], additionalExtensions],
+          },
+          lockScroll: showBase,
         });
         leftWidget = left.remoteEditorWidget;
       }
-      this._gridPanel.addWidget(leftWidget);
+      this.addWidget(leftWidget);
       leftWidget.addClass('cm-merge-left-editor');
 
       if (showBase) {
-        this._gridPanel.addWidget(this._base);
+        this.addWidget(this._base);
         this._base.addClass('cm-central-editor');
       }
 
@@ -1431,26 +1477,80 @@ export class MergeView extends Panel {
           node: elt('div', 'Value missing', 'jp-mod-missing'),
         });
       } else {
-        right = this._right = new DiffView(remote, 'right', {
-          ...options,
-          // Copy configuration
-          config: { ...options.config },
-          extensions: [options.extensions ?? [], additionalExtensions],
+        right = this._right = new DiffView({
+          model: remote,
+          type: 'right',
+          options: {
+            ...options,
+            // Copy configuration
+            config: { ...options.config },
+            extensions: [options.extensions ?? [], additionalExtensions],
+          },
+          lockScroll: showBase,
         });
         rightWidget = right.remoteEditorWidget;
       }
-      this._gridPanel.addWidget(rightWidget);
+      this.addWidget(rightWidget);
       rightWidget.addClass('cm-merge-pane');
       rightWidget.addClass('cm-merge-right-editor');
 
-      merge = this._merge = new DiffView(merged, 'merge', {
-        ...options,
-        // Copy configuration
-        config: { ...options.config, readOnly },
-        extensions: [options.extensions ?? [], additionalExtensions],
+      merge = this._merge = new DiffView({
+        model: merged,
+        type: 'merge',
+        options: {
+          ...options,
+          // Copy configuration
+          config: { ...options.config, readOnly },
+          extensions: [options.extensions ?? [], additionalExtensions],
+        },
+        lockScroll: showBase,
       });
       let mergeWidget = merge.remoteEditorWidget;
-      this._gridPanel.addWidget(mergeWidget);
+      if (showBase) {
+        this.addWidget(mergeWidget);
+      } else {
+        this.insertWidget(1, mergeWidget);
+        // We add scroll synchronization between left, right and merge here
+        // as the base editor is not inserted and the diff view scroll synchronization does not work
+        function addScroll(
+          srcScroller: HTMLElement,
+          destScroller: HTMLElement,
+        ) {
+          srcScroller.addEventListener(
+            'scroll',
+            () => {
+              window.requestAnimationFrame(function () {
+                destScroller.scrollLeft = srcScroller.scrollLeft;
+                destScroller.scrollTop = srcScroller.scrollTop;
+              });
+            },
+            { passive: true },
+          );
+
+          destScroller.addEventListener(
+            'scroll',
+            () => {
+              window.requestAnimationFrame(function () {
+                srcScroller.scrollLeft = destScroller.scrollLeft;
+                srcScroller.scrollTop = destScroller.scrollTop;
+              });
+            },
+            { passive: true },
+          );
+        }
+        if (this.left) {
+          addScroll(
+            this.merge!.remoteEditorWidget.cm.scrollDOM,
+            this.left.remoteEditorWidget.cm.scrollDOM,
+          );
+        }
+        if (this.right) {
+          addScroll(
+            this.merge!.remoteEditorWidget.cm.scrollDOM,
+            this.right.remoteEditorWidget.cm.scrollDOM,
+          );
+        }
+      }
       mergeWidget.addClass('cm-merge-editor');
       //END MERGE CASE
       panes = 3 + (showBase ? 1 : 0);
@@ -1470,11 +1570,15 @@ export class MergeView extends Panel {
         panes = 2;
         this.addWidget(this._base);
         this._base.addClass('cm-diff-left-editor');
-        right = this._right = new DiffView(remote, 'right', {
-          ...options,
-          // Copy configuration
-          config: { ...options.config },
-          extensions: [options.extensions ?? [], additionalExtensions],
+        right = this._right = new DiffView({
+          model: remote,
+          type: 'right',
+          options: {
+            ...options,
+            // Copy configuration
+            config: { ...options.config },
+            extensions: [options.extensions ?? [], additionalExtensions],
+          },
         });
         let rightWidget = right.remoteEditorWidget;
         rightWidget.addClass('cm-diff-right-editor');
@@ -1504,7 +1608,10 @@ export class MergeView extends Panel {
    * Align the matching lines of the different editors
    */
   alignViews() {
-    let lineHeight = this._base.cm.defaultLineHeight;
+    console.log(this._showBase, this._diffViews.length);
+    let lineHeight = this._showBase
+      ? this._base.cm.defaultLineHeight
+      : this._diffViews[0].remoteEditorWidget.cm.defaultLineHeight;
     if (this._aligning) {
       return;
     }
@@ -1513,12 +1620,11 @@ export class MergeView extends Panel {
     let linesToAlign = findAlignedLines(this._diffViews);
 
     // Function modifying DOM to perform alignment:
-    let self: MergeView = this;
-    let editors: EditorView[] = [self.base.cm];
+    let editors: EditorView[] = [
+      this.base.cm,
+      ...this._diffViews.map(dv => dv.remoteEditorWidget.cm),
+    ];
     let builders: RangeSetBuilder<Decoration>[] = [];
-    for (let dv of self._diffViews) {
-      editors.push(dv.remoteEditorWidget.cm);
-    }
     for (let i = 0; i < editors.length; i++) {
       builders.push(new RangeSetBuilder<Decoration>());
     }
@@ -1527,17 +1633,24 @@ export class MergeView extends Panel {
     let nLines = editors.map(editor => editor.state.doc.lines);
 
     for (let alignment_ of linesToAlign) {
-      let alignment = alignment_.slice(0, 3);
+      let alignment = this._showBase ? alignment_.slice(0, 3) : alignment_;
       let lastLine = Math.max(...alignment);
       let lineDeltas = alignment.map(
         (line, i) => lastLine - line - sumDeltas[i],
       );
       // If some paddings will be before the current line, it means all other editors
       // must add a padding.
-      let minDelta = Math.min(...lineDeltas);
+      let minDelta = this._showBase
+        ? Math.min(...lineDeltas)
+        : Math.min(...lineDeltas.slice(1));
       let correctedDeltas = lineDeltas.map(line => line - minDelta);
 
       correctedDeltas.forEach((delta, i) => {
+        // Don't compute anything for the base editor if it is hidden
+        if (!this._showBase && i === 0) {
+          return;
+        }
+
         let side = -1;
         let line = alignment[i];
 
@@ -1565,7 +1678,7 @@ export class MergeView extends Panel {
     // Padding at the last line of the editor
     let totalHeight = nLines.map((line, i) => line + sumDeltas[i]);
     let maxHeight = Math.max(...totalHeight);
-    totalHeight.slice(0, 3).forEach((line, i) => {
+    totalHeight.slice(0, this._showBase ? 3 : 4).forEach((line, i) => {
       if (maxHeight > line) {
         let end = editors[i].state.doc.length;
         let delta = maxHeight - line;
@@ -1582,7 +1695,20 @@ export class MergeView extends Panel {
       }
     });
 
-    for (let i = 0; i < editors.length; i++) {
+    // Don't insert spacers on merge if base is shown
+    for (
+      let i = 0;
+      i <
+      (this._showBase && editors.length > 2
+        ? editors.length - 1
+        : editors.length);
+      i++
+    ) {
+      // Don't update spacers on base if it is hidden.
+      if (i === 0 && !this._showBase) {
+        continue;
+      }
+
       let decoSet: DecorationSet = builders[i].finish();
       if (
         !RangeSet.eq([decoSet], [editors[i].state.field(paddingWidgetField)])
@@ -1785,7 +1911,7 @@ export class MergeView extends Panel {
   private updateDiffViews() {
     this.clearBaseEditorPickers();
     for (let dv of this._diffViews) {
-      dv.updateView(dv.baseEditorWidget.cm, dv.remoteEditorWidget.cm);
+      dv.updateView();
     }
   }
   /**
@@ -1823,13 +1949,13 @@ export class MergeView extends Panel {
   }
 
   private _collapseIdentical: number = 2;
-  private _gridPanel: Panel;
   private _left: DiffView | null;
   private _right: DiffView | null;
   private _merge: DiffView | null;
   private _base: EditorWidget;
   private _aligning: boolean;
   private _measuring: number;
+  private _showBase = true;
 }
 
 /**
diff --git a/packages/nbdime/src/diff/widget/cell.ts b/packages/nbdime/src/diff/widget/cell.ts
index 345890ef..bf33ad34 100644
--- a/packages/nbdime/src/diff/widget/cell.ts
+++ b/packages/nbdime/src/diff/widget/cell.ts
@@ -8,6 +8,8 @@ import type { CodeEditor } from '@jupyterlab/codeeditor';
 
 import { IRenderMimeRegistry, MimeModel } from '@jupyterlab/rendermime';
 
+import { DiffPanel } from '../../common/basepanel';
+
 import { CollapsiblePanel } from '../../common/collapsiblepanel';
 
 import type { ICellDiffWidgetOptions } from '../../common/interfaces';
@@ -56,26 +58,23 @@ export interface ICellDiffViewOptions<T extends IDiffModel = IDiffModel> {
   parent: CellDiffModel;
   editorClasses: string[];
   rendermime: IRenderMimeRegistry;
-  factory?: CodeEditor.Factory;
+  editorFactory?: CodeEditor.Factory;
 }
 
 /**
  * CellDiffWidget for cell changes
  */
-export class CellDiffWidget extends Panel {
+export class CellDiffWidget extends DiffPanel<CellDiffModel> {
   /**
    *
    */
   constructor({
-    editorFactory,
-    model,
     rendermime,
     mimetype,
+    ...others
   }: ICellDiffWidgetOptions<CellDiffModel>) {
-    super();
+    super(others);
     this.addClass(CELLDIFF_CLASS);
-    this._editorFactory = editorFactory;
-    this._model = model;
     this._rendermime = rendermime;
     this.mimetype = mimetype;
 
@@ -105,7 +104,7 @@ export class CellDiffWidget extends Panel {
       parent: model,
       editorClasses: CURR_DIFF_CLASSES,
       rendermime: this._rendermime,
-      factory: this._editorFactory,
+      editorFactory: this._editorFactory,
     });
     sourceView.addClass(SOURCE_ROW_CLASS);
     if (model.executionCount) {
@@ -122,7 +121,7 @@ export class CellDiffWidget extends Panel {
         parent: model,
         editorClasses: CURR_DIFF_CLASSES,
         rendermime: this._rendermime,
-        factory: this._editorFactory,
+        editorFactory: this._editorFactory,
       });
       metadataView.addClass(METADATA_ROW_CLASS);
       this.addWidget(metadataView);
@@ -140,7 +139,7 @@ export class CellDiffWidget extends Panel {
             parent: model,
             editorClasses: CURR_DIFF_CLASSES,
             rendermime: this._rendermime,
-            factory: this._editorFactory,
+            editorFactory: this._editorFactory,
           });
           container.addWidget(outputsWidget);
           changed = changed || !o.unchanged || o.added || o.deleted;
@@ -159,7 +158,7 @@ export class CellDiffWidget extends Panel {
               parent: model,
               editorClasses: CURR_DIFF_CLASSES,
               rendermime: this._rendermime,
-              factory: this._editorFactory,
+              editorFactory: this._editorFactory,
             });
             target.addWidget(outputsWidget);
             changed = changed || !o.unchanged || o.added || o.deleted;
@@ -217,7 +216,7 @@ export class CellDiffWidget extends Panel {
     parent,
     editorClasses,
     rendermime,
-    factory,
+    editorFactory,
   }: ICellDiffViewOptions): Panel {
     let view: Panel;
     if (model instanceof StringDiffModel) {
@@ -234,7 +233,10 @@ export class CellDiffWidget extends Panel {
         renderer.renderModel(mimeModel);
         inner = renderer;
       } else {
-        inner = createNbdimeMergeView({ remote: model, factory });
+        inner = createNbdimeMergeView({
+          remote: model,
+          factory: editorFactory,
+        });
       }
       if (model.collapsible) {
         view = new CollapsiblePanel(
@@ -252,7 +254,7 @@ export class CellDiffWidget extends Panel {
         parent,
         editorClasses,
         rendermime,
-        factory,
+        editorFactory,
       });
       if (model.added) {
         view.addClass(ADDED_DIFF_CLASS);
@@ -281,7 +283,5 @@ export class CellDiffWidget extends Panel {
     return this._model;
   }
 
-  protected _editorFactory: CodeEditor.Factory | undefined;
-  protected _model: CellDiffModel;
   protected _rendermime: IRenderMimeRegistry;
 }
diff --git a/packages/nbdime/src/diff/widget/metadata.ts b/packages/nbdime/src/diff/widget/metadata.ts
index e15ab2b4..57e7874d 100644
--- a/packages/nbdime/src/diff/widget/metadata.ts
+++ b/packages/nbdime/src/diff/widget/metadata.ts
@@ -2,9 +2,9 @@
 // Distributed under the terms of the Modified BSD License.
 'use strict';
 
-import { Panel, Widget } from '@lumino/widgets';
+import type { Widget } from '@lumino/widgets';
 
-import type { CodeEditor } from '@jupyterlab/codeeditor';
+import { DiffPanel } from '../../common/basepanel';
 
 import { createNbdimeMergeView } from '../../common/mergeview';
 
@@ -21,16 +21,11 @@ const ROOT_METADATA_CLASS = 'jp-Metadata-diff';
 /**
  * MetadataWidget for changes to Notebook-level metadata
  */
-export class MetadataDiffWidget extends Panel {
+export class MetadataDiffWidget extends DiffPanel<IStringDiffModel> {
   // TODO improve typing hierarchy to avoid `Omit`
-  constructor({
-    model,
-    editorFactory,
-  }: Omit<IDiffWidgetOptions<IStringDiffModel>, 'rendermime'>) {
-    super();
-    this._editorFactory = editorFactory;
-    this._model = model;
-    console.assert(!model.added && !model.deleted);
+  constructor(options: IDiffWidgetOptions<IStringDiffModel>) {
+    super(options);
+    console.assert(!this._model.added && !this._model.deleted);
     this.addClass(ROOT_METADATA_CLASS);
     this.init();
   }
@@ -53,7 +48,4 @@ export class MetadataDiffWidget extends Panel {
       this.addWidget(view);
     }
   }
-
-  private _editorFactory: CodeEditor.Factory | undefined;
-  private _model: IStringDiffModel;
 }
diff --git a/packages/nbdime/src/diff/widget/notebook.ts b/packages/nbdime/src/diff/widget/notebook.ts
index 084d2175..c3f1d328 100644
--- a/packages/nbdime/src/diff/widget/notebook.ts
+++ b/packages/nbdime/src/diff/widget/notebook.ts
@@ -4,8 +4,6 @@
 
 import { Panel } from '@lumino/widgets';
 
-import type { CodeEditor } from '@jupyterlab/codeeditor';
-
 import type { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
 import { CellDiffWidget } from './cell';
@@ -18,7 +16,9 @@ import {
 
 import { MetadataDiffWidget } from './metadata';
 
-import type { IDiffWidgetOptions } from '../../common/interfaces';
+import { DiffPanel } from '../../common/basepanel';
+
+import type { IMimeDiffWidgetOptions } from '../../common/interfaces';
 
 import type { NotebookDiffModel } from '../model';
 
@@ -27,15 +27,12 @@ const NBDIFF_CLASS = 'jp-Notebook-diff';
 /**
  * NotebookDiffWidget
  */
-export class NotebookDiffWidget extends Panel {
+export class NotebookDiffWidget extends DiffPanel<NotebookDiffModel> {
   constructor({
-    editorFactory,
-    model,
     rendermime,
-  }: IDiffWidgetOptions<NotebookDiffModel>) {
-    super();
-    this._editorFactory = editorFactory;
-    this._model = model;
+    ...others
+  }: IMimeDiffWidgetOptions<NotebookDiffModel>) {
+    super(others);
     this._rendermime = rendermime;
     this.addClass(NBDIFF_CLASS);
   }
@@ -115,7 +112,5 @@ export class NotebookDiffWidget extends Panel {
     return this._model;
   }
 
-  private _editorFactory: CodeEditor.Factory | undefined;
-  private _model: NotebookDiffModel;
   private _rendermime: IRenderMimeRegistry;
 }
diff --git a/packages/nbdime/src/diff/widget/output.ts b/packages/nbdime/src/diff/widget/output.ts
index 4b3e05d8..4cc52090 100644
--- a/packages/nbdime/src/diff/widget/output.ts
+++ b/packages/nbdime/src/diff/widget/output.ts
@@ -8,14 +8,14 @@ import { Panel, Widget } from '@lumino/widgets';
 
 import { each, find, toArray } from '@lumino/algorithm';
 
-import type { CodeEditor } from '@jupyterlab/codeeditor';
-
 import {
   IRenderMimeRegistry,
   OutputModel,
   IRenderMime,
 } from '@jupyterlab/rendermime';
 
+import { DiffPanel } from '../../common/basepanel';
+
 import { CollapsiblePanel } from '../../common/collapsiblepanel';
 
 import { createNbdimeMergeView } from '../../common/mergeview';
@@ -95,33 +95,30 @@ function isBase64(data: string | null, minLength = 64): boolean {
 /**
  * A panel responsible for rendering an output diff
  */
-export class OutputPanel extends Panel {
+export class OutputPanel extends DiffPanel<OutputDiffModel> {
   /**
    *
    */
   constructor({
-    model,
     parent: parentModel,
     editorClasses,
     rendermime,
-    factory,
+    ...others
   }: ICellDiffViewOptions<OutputDiffModel>) {
-    super();
-    this.editorFactory = factory;
-    this.model = model;
+    super(others);
     this.rendermime = rendermime;
     this.editorClasses = editorClasses;
 
-    model.trustedChanged.connect(
+    this._model.trustedChanged.connect(
       (sender: OutputDiffModel, trusted: boolean) => {
         this.trustedChanged(trusted);
       },
     );
-    if (OutputPanel.isTrustSignificant(model, this.rendermime)) {
+    if (OutputPanel.isTrustSignificant(this._model, this.rendermime)) {
       this.addClass(UNTRUSTED_CLASS);
     }
 
-    if (model.added) {
+    if (this._model.added) {
       if (!parentModel.added) {
         // Implies this is added output
         let addSpacer = new Widget();
@@ -130,7 +127,7 @@ export class OutputPanel extends Panel {
         this.addWidget(addSpacer);
       }
       this.addClass(ADDED_DIFF_CLASS);
-    } else if (model.deleted) {
+    } else if (this._model.deleted) {
       if (!parentModel.deleted) {
         // Implies this is deleted output
         let delSpacer = new Widget();
@@ -139,7 +136,7 @@ export class OutputPanel extends Panel {
         this.addWidget(delSpacer);
       }
       this.addClass(DELETED_DIFF_CLASS);
-    } else if (model.unchanged) {
+    } else if (this._model.unchanged) {
       this.addClass(UNCHANGED_DIFF_CLASS);
     } else {
       this.addClass(TWOWAY_DIFF_CLASS);
@@ -156,11 +153,11 @@ export class OutputPanel extends Panel {
    * Add view to panel, possibly wrapped
    */
   protected initContainer(view: Widget) {
-    if (this.model.collapsible) {
+    if (this._model.collapsible) {
       this.container = new CollapsiblePanel(
         view,
-        this.model.collapsibleHeader,
-        this.model.startCollapsed,
+        this._model.collapsibleHeader,
+        this._model.startCollapsed,
       );
     } else {
       this.container = this;
@@ -185,7 +182,7 @@ export class OutputPanel extends Panel {
    */
   protected createView(forceText = false): Widget {
     let view: Widget | null = null;
-    let model = this.model;
+    let model = this._model;
     let rendermime = this.rendermime;
     // Take one of three actions, depending on output types
     // 1) Renderable types: Side-by-side comparison.
@@ -217,7 +214,7 @@ export class OutputPanel extends Panel {
     // 2) Known, non-binary MIME: Show a MergeView with text diff.
     // 3) Unknown types: Stringified JSON diff.
     let view: Widget | undefined;
-    let model = this.model as OutputDiffModel;
+    let model = this._model as OutputDiffModel;
     // Find highest order MIME-type supported by rendermime
     let key: string | string[] | null = null;
     if (this.selectedMimetype === null) {
@@ -235,7 +232,7 @@ export class OutputPanel extends Panel {
         // 2.
         view = createNbdimeMergeView({
           remote: stringModel,
-          factory: this.editorFactory,
+          factory: this._editorFactory,
         });
       }
     }
@@ -243,7 +240,7 @@ export class OutputPanel extends Panel {
       // 3.
       view = createNbdimeMergeView({
         remote: model.stringify(),
-        factory: this.editorFactory,
+        factory: this._editorFactory,
       });
     }
     return view;
@@ -277,7 +274,7 @@ export class OutputPanel extends Panel {
     btnTrust.innerText = 'Trust';
     btnTrust.onclick = (ev: MouseEvent) => {
       // Triggers change event:
-      this.model.trusted = !this.model.trusted;
+      this._model.trusted = !this._model.trusted;
     };
     w = new Widget({ node: btnTrust });
     w.addClass(TRUST_BUTTON_CLASS);
@@ -285,7 +282,7 @@ export class OutputPanel extends Panel {
 
     // Add mimetype select:
     let mimetypes: string[] = [];
-    for (let output of this.model.contents) {
+    for (let output of this._model.contents) {
       let bundle = OutputModel.getData(output);
       mimetypes = mimetypes.concat(Object.keys(bundle));
     }
@@ -318,7 +315,7 @@ export class OutputPanel extends Panel {
     this.updateView();
     if (trusted) {
       this.removeClass(UNTRUSTED_CLASS);
-    } else if (OutputPanel.isTrustSignificant(this.model, this.rendermime)) {
+    } else if (OutputPanel.isTrustSignificant(this._model, this.rendermime)) {
       this.addClass(UNTRUSTED_CLASS);
     }
   }
@@ -327,7 +324,7 @@ export class OutputPanel extends Panel {
    * Update view
    */
   protected updateView(): void {
-    let model = this.model;
+    let model = this._model;
     if (this.view instanceof RenderableOutputView) {
       // Previosuly rendered
       if (
@@ -353,10 +350,10 @@ export class OutputPanel extends Panel {
     if (this._mimetype !== null) {
       return this._mimetype;
     }
-    let data = OutputModel.getData(this.model.base || this.model.remote!);
+    let data = OutputModel.getData(this._model.base || this._model.remote!);
     let mt = this.rendermime.preferredMimeType(
       data,
-      this.model.trusted ? 'any' : 'ensure',
+      this._model.trusted ? 'any' : 'ensure',
     );
     return mt === undefined ? null : mt;
   }
@@ -368,8 +365,6 @@ export class OutputPanel extends Panel {
     }
   }
 
-  protected editorFactory: CodeEditor.Factory | undefined;
-  protected model: OutputDiffModel;
   protected rendermime: IRenderMimeRegistry;
   protected editorClasses: string[];
   protected container: Panel;
diff --git a/packages/nbdime/src/merge/widget/cell.ts b/packages/nbdime/src/merge/widget/cell.ts
index af256fd4..45a9c5f2 100644
--- a/packages/nbdime/src/merge/widget/cell.ts
+++ b/packages/nbdime/src/merge/widget/cell.ts
@@ -10,11 +10,16 @@ import type { CodeEditor } from '@jupyterlab/codeeditor';
 
 import type { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
+import { MergePanel } from '../../common/basepanel';
+
 import { CollapsiblePanel } from '../../common/collapsiblepanel';
 
 import { DragPanel } from '../../common/dragpanel';
 
-import type { ICellDiffWidgetOptions } from '../../common/interfaces';
+import type {
+  ICellDiffWidgetOptions,
+  IMergeWidgetOptions,
+} from '../../common/interfaces';
 
 import { createNbdimeMergeView, MergeView } from '../../common/mergeview';
 
@@ -74,14 +79,15 @@ export interface ICellMergeViewOptions {
 /**
  * CellMergeWidget for cell changes
  */
-export class CellMergeWidget extends Panel {
+export class CellMergeWidget extends MergePanel<CellMergeModel> {
   static createMergeView({
     editorFactory,
     local,
     remote,
     merged,
     readOnly,
-  }: ICellMergeViewOptions): Widget | null {
+    ...others
+  }: ICellMergeViewOptions & IMergeWidgetOptions): Widget | null {
     let view: Widget | null = null;
     if (merged instanceof StringDiffModel) {
       view = createNbdimeMergeView({
@@ -90,6 +96,7 @@ export class CellMergeWidget extends Panel {
         merged,
         readOnly: readOnly ?? false,
         factory: editorFactory,
+        ...others,
       });
     }
     return view;
@@ -118,15 +125,12 @@ export class CellMergeWidget extends Panel {
    *
    */
   constructor({
-    editorFactory,
-    model,
     rendermime,
     mimetype,
+    ...options
   }: ICellDiffWidgetOptions<CellMergeModel>) {
-    super();
+    super(options);
     this.addClass(CELLMERGE_CLASS);
-    this._editorFactory = editorFactory;
-    this._model = model;
     this._rendermime = rendermime;
     this.mimetype = mimetype;
 
@@ -211,7 +215,7 @@ export class CellMergeWidget extends Panel {
         parent: model.merged,
         editorClasses: CURR_CLASSES,
         rendermime: this._rendermime,
-        factory: this._editorFactory,
+        editorFactory: this._editorFactory,
       });
       if ((ladd && !radd) || (ldel && !rdel)) {
         this.addClass(ONEWAY_LOCAL_CLASS);
@@ -236,7 +240,7 @@ export class CellMergeWidget extends Panel {
             parent: model.merged,
             editorClasses: CURR_CLASSES,
             rendermime: this._rendermime,
-            factory: this._editorFactory,
+            editorFactory: this._editorFactory,
           });
           container.addWidget(view);
         }
@@ -269,7 +273,7 @@ export class CellMergeWidget extends Panel {
           parent: model.merged,
           editorClasses: CURR_CLASSES,
           rendermime: this._rendermime,
-          factory: this._editorFactory,
+          editorFactory: this._editorFactory,
         });
       } else {
         sourceView = CellMergeWidget.createMergeView({
@@ -278,6 +282,7 @@ export class CellMergeWidget extends Panel {
           merged: model.merged.source,
           editorClasses: CURR_CLASSES,
           editorFactory: this._editorFactory,
+          ...this._viewOptions,
         });
       }
       if (sourceView === null) {
@@ -310,8 +315,9 @@ export class CellMergeWidget extends Panel {
           remote: model.remote ? model.remote.metadata : null,
           merged: model.merged.metadata,
           editorClasses: CURR_CLASSES,
-          readOnly: true, // Do not allow manual edit of metadata
           editorFactory: this._editorFactory,
+          ...this._viewOptions,
+          readOnly: true, // Do not allow manual edit of metadata
         });
         if (metadataView === null) {
           throw new Error(
@@ -348,6 +354,7 @@ export class CellMergeWidget extends Panel {
           baseOut,
           remoteOut,
           localOut,
+          this._viewOptions.showBase,
         );
         this.outputViews = view;
 
@@ -482,7 +489,5 @@ export class CellMergeWidget extends Panel {
     return this._model;
   }
 
-  private _editorFactory: CodeEditor.Factory | undefined;
-  private _model: CellMergeModel;
   private _rendermime: IRenderMimeRegistry;
 }
diff --git a/packages/nbdime/src/merge/widget/metadata.ts b/packages/nbdime/src/merge/widget/metadata.ts
index 2075d37b..8f857d4f 100644
--- a/packages/nbdime/src/merge/widget/metadata.ts
+++ b/packages/nbdime/src/merge/widget/metadata.ts
@@ -2,18 +2,19 @@
 // Distributed under the terms of the Modified BSD License.
 'use strict';
 
-import type { CodeEditor } from '@jupyterlab/codeeditor';
-
 import type * as nbformat from '@jupyterlab/nbformat';
 
-import { Panel } from '@lumino/widgets';
-
-import type { IDiffWidgetOptions } from '../../common/interfaces';
+import type {
+  IDiffWidgetOptions,
+  IMergeWidgetOptions,
+} from '../../common/interfaces';
 
 import { createNbdimeMergeView, MergeView } from '../../common/mergeview';
 
 import { CollapsiblePanel } from '../../common/collapsiblepanel';
 
+import { MergePanel } from '../../common/basepanel';
+
 import type { MetadataMergeModel } from '../model';
 
 const ROOT_METADATA_CLASS = 'jp-Metadata-diff';
@@ -21,20 +22,17 @@ const ROOT_METADATA_CLASS = 'jp-Metadata-diff';
 /**
  * MetadataWidget for changes to Notebook-level metadata
  */
-export class MetadataMergeWidget extends Panel {
-  constructor({
-    model,
-    editorFactory,
-  }: Omit<IDiffWidgetOptions<MetadataMergeModel>, 'rendermime'>) {
-    super();
-    this._editorFactory = editorFactory;
-    this._model = model;
+export class MetadataMergeWidget extends MergePanel<MetadataMergeModel> {
+  constructor(
+    options: IDiffWidgetOptions<MetadataMergeModel> & IMergeWidgetOptions,
+  ) {
+    super(options);
     this.addClass(ROOT_METADATA_CLASS);
     this.init();
   }
 
   init() {
-    let model = this._model;
+    const model = this._model;
 
     // We know/assume that MetadataMergeModel never has
     // null values for local/remote:
@@ -43,8 +41,9 @@ export class MetadataMergeWidget extends Panel {
       local: model.local,
       merged: model.merged,
       factory: this._editorFactory,
+      ...this._viewOptions,
     });
-    let wrapper = new CollapsiblePanel(
+    const wrapper = new CollapsiblePanel(
       this.view,
       'Notebook metadata changed',
       true,
@@ -55,7 +54,7 @@ export class MetadataMergeWidget extends Panel {
   validateMerged(
     candidate: nbformat.INotebookMetadata,
   ): nbformat.INotebookMetadata {
-    let text = this.view.getMergedValue();
+    const text = this.view.getMergedValue();
     if (JSON.stringify(candidate) !== text) {
       // This will need to be validated server side,
       // and should not be touched by client side
@@ -66,7 +65,4 @@ export class MetadataMergeWidget extends Panel {
   }
 
   protected view: MergeView;
-
-  private _editorFactory: CodeEditor.Factory | undefined;
-  private _model: MetadataMergeModel;
 }
diff --git a/packages/nbdime/src/merge/widget/notebook.ts b/packages/nbdime/src/merge/widget/notebook.ts
index 27f68f84..f8d5a789 100644
--- a/packages/nbdime/src/merge/widget/notebook.ts
+++ b/packages/nbdime/src/merge/widget/notebook.ts
@@ -4,13 +4,14 @@
 
 import type * as nbformat from '@jupyterlab/nbformat';
 
-import type { CodeEditor } from '@jupyterlab/codeeditor';
-
 import type { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 
-import { Panel } from '@lumino/widgets';
+import { MergePanel } from '../../common/basepanel';
 
-import type { IDiffWidgetOptions } from '../../common/interfaces';
+import type {
+  IMergeWidgetOptions,
+  IMimeDiffWidgetOptions,
+} from '../../common/interfaces';
 
 import { hasEntries, deepCopy } from '../../common/util';
 
@@ -32,15 +33,12 @@ const NB_MERGE_CONTROLS_CLASS = 'jp-Merge-notebook-controls';
 /**
  * NotebookMergeWidget
  */
-export class NotebookMergeWidget extends Panel {
+export class NotebookMergeWidget extends MergePanel<NotebookMergeModel> {
   constructor({
-    editorFactory,
-    model,
     rendermime,
-  }: IDiffWidgetOptions<NotebookMergeModel>) {
-    super();
-    this._editorFactory = editorFactory;
-    this._model = model;
+    ...options
+  }: IMimeDiffWidgetOptions<NotebookMergeModel> & IMergeWidgetOptions) {
+    super(options);
     this._rendermime = rendermime;
 
     this.addClass(NBMERGE_CLASS);
@@ -62,6 +60,7 @@ export class NotebookMergeWidget extends Panel {
         this.metadataWidget = new MetadataMergeWidget({
           model: model.metadata,
           editorFactory: this._editorFactory,
+          ...this._viewOptions,
         });
         this.addWidget(this.metadataWidget);
       }
@@ -84,6 +83,7 @@ export class NotebookMergeWidget extends Panel {
             rendermime,
             mimetype: model.mimetype,
             editorFactory: this._editorFactory,
+            ...this._viewOptions,
           });
           this.cellWidgets.push(w);
           if (c.onesided && c.conflicted) {
@@ -196,8 +196,6 @@ export class NotebookMergeWidget extends Panel {
   protected cellWidgets: CellMergeWidget[];
   protected cellContainer: CellsDragDrop;
 
-  private _editorFactory: CodeEditor.Factory | undefined;
-  private _model: NotebookMergeModel;
   private _rendermime: IRenderMimeRegistry;
 }
 
diff --git a/packages/nbdime/src/merge/widget/output.ts b/packages/nbdime/src/merge/widget/output.ts
index 9cd6a28d..dc3d0556 100644
--- a/packages/nbdime/src/merge/widget/output.ts
+++ b/packages/nbdime/src/merge/widget/output.ts
@@ -137,8 +137,10 @@ export class RenderableOutputsMergeView extends DragDropPanel {
     base: nbformat.IOutput[] | null,
     remote: nbformat.IOutput[] | null,
     local: nbformat.IOutput[] | null,
+    showBase = true,
   ) {
     super();
+    this.showBase = showBase;
     this.addClass(REORDERABLE_OUTPUT_CLASS);
 
     if (!base !== !remote || !base !== !local) {
@@ -184,7 +186,7 @@ export class RenderableOutputsMergeView extends DragDropPanel {
       row.addWidget(leftPane);
       this.panes.push(leftPane);
     }
-    if (this.base) {
+    if (this.base && this.showBase) {
       let basePane = new OutputArea({
         model: this.base,
         rendermime: this.rendermime,
@@ -386,4 +388,6 @@ export class RenderableOutputsMergeView extends DragDropPanel {
   panes: OutputArea[];
 
   rendermime: IRenderMimeRegistry;
+
+  protected showBase: boolean;
 }
diff --git a/packages/nbdime/src/styles/common.css b/packages/nbdime/src/styles/common.css
index fff17509..eb4d097c 100644
--- a/packages/nbdime/src/styles/common.css
+++ b/packages/nbdime/src/styles/common.css
@@ -29,7 +29,8 @@
   background: var(--jp-cell-editor-background);
 }
 
-.nbdime-root .cm-merge-grid-panel {
+.nbdime-root .cm-merge-3pane,
+.nbdime-root .cm-merge-4pane {
   display: grid;
   grid-template-columns: repeat(3, 1fr);
   gap: 10px;
@@ -51,7 +52,7 @@
   grid-row: 1;
 }
 
-.nbdime-root .cm-merge-editor {
+.nbdime-root .cm-merge-4pane > .cm-merge-editor {
   grid-column: 1/4;
   grid-row: 2;
 }
diff --git a/packages/webapp/src/app/merge.css b/packages/webapp/src/app/merge.css
index f4941bf1..258b6569 100644
--- a/packages/webapp/src/app/merge.css
+++ b/packages/webapp/src/app/merge.css
@@ -5,11 +5,10 @@
   z-index: 0;
 }
 
-.nbdime-Merge #nbdime-header-base,
-.nbdime-Merge #nbdime-header-remote,
-.nbdime-Merge #nbdime-header-local {
-  display: inline-block;
-  width: 33%;
+.nbdime-Merge #nbdime-header-banner {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 10px;
 }
 
 .nbdime-Merge #nbdime-header-local {
diff --git a/packages/webapp/src/app/merge.ts b/packages/webapp/src/app/merge.ts
index 9309dc1d..727d268c 100644
--- a/packages/webapp/src/app/merge.ts
+++ b/packages/webapp/src/app/merge.ts
@@ -54,11 +54,18 @@ function showMerge(data: {
     sanitizer: new Sanitizer(),
   });
 
+  const showBase = getConfigOption('showBase', true);
+
+  if (!showBase) {
+    document.querySelector('#nbdime-header-base')!.textContent = 'Merged';
+  }
+
   let nbmModel = new NotebookMergeModel(data.base, data.merge_decisions);
   let nbmWidget = new NotebookMergeWidget({
     model: nbmModel,
     rendermime,
     editorFactory: createEditorFactory(),
+    showBase,
   });
 
   let root = document.getElementById('nbdime-root');


Test patch:
diff --git a/ui-tests/tests/nbdime-merge-test1.spec.ts b/ui-tests/tests/nbdime-merge-test1.spec.ts
index 8ce73c38..dbf48598 100644
--- a/ui-tests/tests/nbdime-merge-test1.spec.ts
+++ b/ui-tests/tests/nbdime-merge-test1.spec.ts
@@ -1,15 +1,15 @@
 import { expect, test } from '@playwright/test';
 
-test.beforeEach(async ({ page }) => {
-  await page.goto('http://localhost:41000/merge');
-  await page.locator('#merge-local').fill('data/merge_test1/left.ipynb');
-  await page.locator('#merge-base').fill('data/merge_test1/center.ipynb');
-  await page.locator('#merge-remote').fill('data/merge_test1/right.ipynb');
-  await page.getByRole('button', { name: 'Merge files' }).click();
-});
-
 /* notebooks of same length and 1 conflict*/
 test.describe('merge test1', () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto('http://localhost:41000/merge');
+    await page.locator('#merge-local').fill('data/merge_test1/left.ipynb');
+    await page.locator('#merge-base').fill('data/merge_test1/center.ipynb');
+    await page.locator('#merge-remote').fill('data/merge_test1/right.ipynb');
+    await page.getByRole('button', { name: 'Merge files' }).click();
+  });
+
   test('take a snapshot at opening', async ({ page }) => {
     await expect.soft(page.getByText('➭')).toHaveCount(12);
     expect.soft(await page.locator('#main').screenshot()).toMatchSnapshot();
@@ -65,3 +65,31 @@ test.describe('merge test1', () => {
     expect(await download1.failure()).toBeNull();
   });
 });
+
+test('3 panels view', async ({ page }) => {
+  const ctxt = page.context();
+  page.route(/.+\/merge/, async (route, request) => {
+    const response = await ctxt.request.fetch(request);
+    if (!response.ok()) {
+      route.abort();
+      return;
+    }
+
+    const buffer = await response!.body();
+    const content = buffer.toString();
+    route.fulfill({
+      body: content.replace('"showBase": true', '"showBase": false'),
+    });
+  });
+
+  // Load the page
+  await page.goto('http://localhost:41000/merge');
+  await page.locator('#merge-local').fill('data/merge_test1/left.ipynb');
+  await page.locator('#merge-base').fill('data/merge_test1/center.ipynb');
+  await page.locator('#merge-remote').fill('data/merge_test1/right.ipynb');
+  await page.getByRole('button', { name: 'Merge files' }).click();
+
+  await expect.soft(page.getByText('➭')).toHaveCount(8);
+
+  expect(await page.locator('#main').screenshot()).toMatchSnapshot();
+});
diff --git a/ui-tests/tests/nbdime-merge-test1.spec.ts-snapshots/3-panels-view-1-linux.png b/ui-tests/tests/nbdime-merge-test1.spec.ts-snapshots/3-panels-view-1-linux.png
new file mode 100644
index 00000000..95ab27da
Binary files /dev/null and b/ui-tests/tests/nbdime-merge-test1.spec.ts-snapshots/3-panels-view-1-linux.png differ
diff --git a/ui-tests/tests/nbdime-merge-test1.spec.ts-snapshots/3-panels-view-1-win32.png b/ui-tests/tests/nbdime-merge-test1.spec.ts-snapshots/3-panels-view-1-win32.png
new file mode 100644
index 00000000..9e42e330
Binary files /dev/null and b/ui-tests/tests/nbdime-merge-test1.spec.ts-snapshots/3-panels-view-1-win32.png differ
diff --git a/ui-tests/tests/nbdime-merge-test3.spec.ts b/ui-tests/tests/nbdime-merge-test3.spec.ts
index 6757b059..ea2f52bf 100644
--- a/ui-tests/tests/nbdime-merge-test3.spec.ts
+++ b/ui-tests/tests/nbdime-merge-test3.spec.ts
@@ -11,7 +11,7 @@ test.beforeEach(async ({ page }) => {
 /* 2 cells with merge conflict */
 test.describe('merge test3', () => {
   test('should warn for remaining conflicts', async ({ page }) => {
-    await expect.soft(page.getByText('➭')).toHaveCount(25);
+    await expect.soft(page.getByText('➭')).toHaveCount(26);
 
     await page.getByRole('button', { name: 'Download' }).click();