This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 6850757c34 more options for splitting tabs and moving to external 
view, fixes #6708 (#7580)
6850757c34 is described below

commit 6850757c34519a37502120d0b0090800ed6988a8
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Jul 20 18:58:18 2026 +0200

    more options for splitting tabs and moving to external view, fixes #6708 
(#7580)
---
 assemblies/static/src/main/resources/hop-gui.bat   |   4 +-
 assemblies/static/src/main/resources/hop-gui.sh    |   3 +-
 .../org/apache/hop/ui/core/gui/GuiResource.java    |   5 +
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  19 +
 .../ui/hopgui/delegates/HopGuiAuditDelegate.java   |  31 +-
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  | 184 ++++-
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  | 180 ++++-
 .../hop/ui/hopgui/perspective/IHopPerspective.java |  28 +
 .../hop/ui/hopgui/perspective/TabItemReorder.java  | 370 +++++++++-
 .../perspective/explorer/ExplorerPerspective.java  | 807 ++++++++++++++++-----
 .../perspective/metadata/MetadataPerspective.java  |  62 +-
 .../hop/ui/hopgui/terminal/HopGuiBottomDock.java   |  44 ++
 .../workflow/messages/messages_en_US.properties    |   3 +
 .../ui/hopgui/messages/messages_en_US.properties   |   3 +
 .../explorer/messages/messages_en_US.properties    |   1 +
 ui/src/main/resources/ui/images/detach-panel.svg   |   4 +
 ui/src/main/resources/ui/images/dock-panel.svg     |   4 +
 17 files changed, 1460 insertions(+), 292 deletions(-)

diff --git a/assemblies/static/src/main/resources/hop-gui.bat 
b/assemblies/static/src/main/resources/hop-gui.bat
index fa830f4630..e6ef8c93e7 100755
--- a/assemblies/static/src/main/resources/hop-gui.bat
+++ b/assemblies/static/src/main/resources/hop-gui.bat
@@ -74,8 +74,8 @@ REM
 REM If the user passes in DEBUG as the first parameter, it starts Hop in 
debugger mode and opens port 5005
 REM to allow attaching a debugger to step code.
 if [%1]==[DEBUG] (
-REM # optional line for attaching a debugger
-set HOP_OPTIONS=%HOP_OPTIONS% -Xdebug -Xnoagent 
-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005)
+REM # optional line for attaching a debugger + turning on GUI debug logging
+set HOP_OPTIONS=%HOP_OPTIONS% -Xdebug -Xnoagent 
-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 
-DHOP_LOG_LEVEL=Debug)
 
 REM Pass HOP variables if they're set.
 if not "%HOP_AUDIT_FOLDER%"=="" (
diff --git a/assemblies/static/src/main/resources/hop-gui.sh 
b/assemblies/static/src/main/resources/hop-gui.sh
index 69f3c6a4b8..ec2dbc546f 100755
--- a/assemblies/static/src/main/resources/hop-gui.sh
+++ b/assemblies/static/src/main/resources/hop-gui.sh
@@ -37,10 +37,11 @@ if [ -z "${HOP_OPTIONS}" ]; then
   HOP_OPTIONS="-Xmx2048m"
 fi
 
-# optional line for attaching a debugger
+# optional line for attaching a debugger + turning on GUI debug logging
 #
 if [ "$1" = "debug" ] || [ "$1" = "DEBUG" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -Xdebug -Xnoagent 
-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005"
+  HOP_OPTIONS="${HOP_OPTIONS} -DHOP_LOG_LEVEL=Debug"
 fi
 
 # Add HOP variables if they're set:
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
index 4c4ee136df..919134384a 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
@@ -221,6 +221,8 @@ public class GuiResource {
   @Getter private Image imageLocation;
   @Getter private Image imageMaximizePanel;
   @Getter private Image imageMinimizePanel;
+  @Getter private Image imageDetachPanel;
+  @Getter private Image imageDockPanel;
   @Getter private Image imageNavigateBack;
   @Getter private Image imageNavigateForward;
   @Getter private Image imageNavigateUp;
@@ -762,6 +764,9 @@ public class GuiResource {
         loadAsResource(display, "ui/images/maximize-panel.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageMinimizePanel =
         loadAsResource(display, "ui/images/minimize-panel.svg", 
ConstUi.SMALL_ICON_SIZE);
+    imageDetachPanel =
+        loadAsResource(display, "ui/images/detach-panel.svg", 
ConstUi.SMALL_ICON_SIZE);
+    imageDockPanel = loadAsResource(display, "ui/images/dock-panel.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageNew = loadAsResource(display, "ui/images/new.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageNote = loadAsResource(display, "ui/images/note.svg", 
ConstUi.SMALL_ICON_SIZE);
     imagePlugin = loadAsResource(display, "ui/images/plugin.svg", 
ConstUi.SMALL_ICON_SIZE);
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index c0163521e7..f04630659a 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -56,10 +56,12 @@ import 
org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
 import org.apache.hop.core.gui.plugin.key.KeyboardShortcut;
 import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.logging.DefaultLogLevel;
 import org.apache.hop.core.logging.HopLogStore;
 import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.logging.ILoggingObject;
 import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.logging.LogLevel;
 import org.apache.hop.core.logging.LoggingObject;
 import org.apache.hop.core.parameters.INamedParameterDefinitions;
 import org.apache.hop.core.plugins.JarCache;
@@ -388,8 +390,25 @@ public class HopGui
     notifyWebThemePreferenceChanged(Boolean.valueOf(darkMode));
   }
 
+  /**
+   * Apply a startup log level from the {@code HOP_LOG_LEVEL} system property 
(e.g. set by {@code
+   * hop-gui.sh debug}). Sets the default level so newly created channels 
inherit it, and updates
+   * the already-created static UI/GENERAL channels so GUI-side debug logging 
becomes visible.
+   */
+  private static void applyStartupLogLevel() {
+    String levelCode = System.getProperty("HOP_LOG_LEVEL");
+    if (StringUtils.isEmpty(levelCode)) {
+      return;
+    }
+    LogLevel level = LogLevel.lookupCode(levelCode);
+    DefaultLogLevel.setLogLevel(level);
+    LogChannel.UI.setLogLevel(level);
+    LogChannel.GENERAL.setLogLevel(level);
+  }
+
   public static void main(String[] arguments) {
     try {
+      applyStartupLogLevel();
       setupConsoleLogging();
       if (!HopEnvironment.isInitialized()) {
         HopEnvironment.init();
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java
index 50bf6c5dab..a075a66a3f 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java
@@ -46,6 +46,7 @@ import 
org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import org.apache.hop.ui.hopgui.perspective.metadata.MetadataPerspective;
 import org.apache.hop.ui.util.SwtErrorHandler;
 import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
 
 public class HopGuiAuditDelegate {
 
@@ -158,23 +159,17 @@ public class HopGuiAuditDelegate {
                       
HopFileTypeRegistry.getInstance().findHopFileType(resolvedFilename);
                 }
 
-                // Set target pane for Explorer split view before opening
+                // Route the file into its saved editor pane (restored layout 
tree) before opening.
                 if (perspective instanceof ExplorerPerspective 
explorerPerspective) {
                   AuditState auditState = auditStateMap.get(tabKey);
                   Object paneObj =
                       auditState != null ? 
auditState.getStateMap().get(STATE_PROPERTY_PANE) : null;
-                  int pane = 0;
-                  if (paneObj instanceof Number) {
-                    pane = ((Number) paneObj).intValue();
-                  } else if (paneObj != null) {
-                    try {
-                      pane = Integer.parseInt(paneObj.toString());
-                    } catch (NumberFormatException ignored) {
-                      pane = 0;
-                    }
-                  }
-                  if (pane == 1 && explorerPerspective.getRightTabFolder() != 
null) {
-                    
perspective.setDropTargetFolder(explorerPerspective.getRightTabFolder());
+                  CTabFolder targetFolder =
+                      paneObj != null
+                          ? 
explorerPerspective.getTabFolderForLeafId(paneObj.toString())
+                          : null;
+                  if (targetFolder != null) {
+                    perspective.setDropTargetFolder(targetFolder);
                   }
                 }
 
@@ -220,6 +215,11 @@ public class HopGuiAuditDelegate {
         if (activeFileTypeHandler != null) {
           perspective.setActiveFileTypeHandler(activeFileTypeHandler);
         }
+
+        // Drop any restored editor pane whose file(s) failed to open, so no 
ghost panes remain.
+        if (perspective instanceof ExplorerPerspective explorerPerspective) {
+          explorerPerspective.finishEditorLayoutRestore();
+        }
       }
     }
 
@@ -350,7 +350,10 @@ public class HopGuiAuditDelegate {
               stateProperties.put(STATE_PROPERTY_FILETYPE, fileType.getName());
             }
             if (perspective instanceof ExplorerPerspective ep) {
-              stateProperties.put(STATE_PROPERTY_PANE, 
ep.getPaneIndexForTab(tabItem.getTabItem()));
+              String leafId = ep.getLeafIdForTab(tabItem.getTabItem());
+              if (leafId != null) {
+                stateProperties.put(STATE_PROPERTY_PANE, leafId);
+              }
             }
 
             auditStateMap.add(new AuditState(tabKey, stateProperties));
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 522a8cbc37..3d6edc3ad8 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -382,6 +382,12 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
   public CTabFolder extraViewTabFolder;
 
+  /**
+   * When the execution results (log/metrics) panel is detached into its own 
floating window, this
+   * holds that window; it is {@code null} while the panel is docked in {@link 
#sashForm}.
+   */
+  private Shell extraViewShell;
+
   private boolean initialized;
 
   private boolean halted;
@@ -4705,6 +4711,13 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     extraViewTabFolder.dispose();
     extraViewTabFolder = null;
 
+    // If the panel was floating, close its window too.
+    if (extraViewShell != null && !extraViewShell.isDisposed()) {
+      Shell shell = extraViewShell;
+      extraViewShell = null;
+      shell.dispose();
+    }
+
     sashForm.layout();
     sashForm.setWeights(100);
 
@@ -4752,12 +4765,25 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   private ToolItem minMaxItem;
   private ToolItem rotateItem;
 
-  /** Add an extra view to the main composite SashForm */
+  /** Add the extra view (log/metrics) docked in the main composite SashForm. 
*/
   public void addExtraView() {
+    addExtraView(sashForm);
+  }
+
+  /**
+   * Build the execution-results tab folder inside the given parent. When 
{@code parent} is the
+   * pipeline's own {@link #sashForm} the panel is docked (with min/max, 
rotate and detach
+   * controls); otherwise it is being hosted in a floating window (with a dock 
control instead). The
+   * delegates always (re)build their tabs into the {@link 
#extraViewTabFolder} field, so switching
+   * parents is a dispose-and-rebuild rather than an SWT reparent.
+   *
+   * @param parent the composite to host the tab folder (the sash when docked, 
a floating shell
+   *     otherwise)
+   */
+  public void addExtraView(Composite parent) {
+    boolean detached = parent != sashForm;
 
-    // Always use standalone mode - execution results render in pipeline's own 
sashForm
-    // Add a tab folder in the pipeline's sashForm
-    extraViewTabFolder = new CTabFolder(sashForm, SWT.MULTI);
+    extraViewTabFolder = new CTabFolder(parent, SWT.MULTI);
     PropsUi.setLook(extraViewTabFolder, Props.WIDGET_STYLE_TAB);
 
     // Layout the tab folder to fill its parent
@@ -4768,39 +4794,56 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     fdTabFolder.bottom = new FormAttachment(100, 0);
     extraViewTabFolder.setLayoutData(fdTabFolder);
 
-    extraViewTabFolder.addMouseListener(
-        new MouseAdapter() {
-
-          @Override
-          public void mouseDoubleClick(MouseEvent arg0) {
-            if (sashForm.getMaximizedControl() == null) {
-              sashForm.setMaximizedControl(extraViewTabFolder);
-            } else {
-              sashForm.setMaximizedControl(null);
+    // Double-click to maximize only makes sense while docked in the sash.
+    if (!detached) {
+      extraViewTabFolder.addMouseListener(
+          new MouseAdapter() {
+
+            @Override
+            public void mouseDoubleClick(MouseEvent arg0) {
+              if (sashForm.getMaximizedControl() == null) {
+                sashForm.setMaximizedControl(extraViewTabFolder);
+              } else {
+                sashForm.setMaximizedControl(null);
+              }
             }
-          }
-        });
+          });
+    }
 
-    // Create toolbar for close and min/max to the upper right corner...
+    // Create toolbar for the panel controls in the upper right corner...
     //
     ToolBar extraViewToolBar = new ToolBar(extraViewTabFolder, SWT.FLAT);
     extraViewTabFolder.setTopRight(extraViewToolBar, SWT.RIGHT);
     PropsUi.setLook(extraViewToolBar);
 
-    minMaxItem = new ToolItem(extraViewToolBar, SWT.PUSH);
-    minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
-    minMaxItem.setToolTipText(
-        BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
-    minMaxItem.addListener(SWT.Selection, e -> minMaxExtraView());
-
-    rotateItem = new ToolItem(extraViewToolBar, SWT.PUSH);
-    rotateItem.setImage(
-        PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
-            ? GuiResource.getInstance().getImageRotateRight()
-            : GuiResource.getInstance().getImageRotateLeft());
-    rotateItem.setToolTipText(
-        BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.RotateButton.Tooltip"));
-    rotateItem.addListener(SWT.Selection, e -> rotateExtraView());
+    if (detached) {
+      ToolItem dockItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      dockItem.setImage(GuiResource.getInstance().getImageDockPanel());
+      dockItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.DockButton.Tooltip"));
+      dockItem.addListener(SWT.Selection, e -> dockExtraView());
+    } else {
+      minMaxItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
+      minMaxItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
+      minMaxItem.addListener(SWT.Selection, e -> minMaxExtraView());
+
+      rotateItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      rotateItem.setImage(
+          PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
+              ? GuiResource.getInstance().getImageRotateRight()
+              : GuiResource.getInstance().getImageRotateLeft());
+      rotateItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.RotateButton.Tooltip"));
+      rotateItem.addListener(SWT.Selection, e -> rotateExtraView());
+
+      ToolItem detachItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      detachItem.setImage(GuiResource.getInstance().getImageDetachPanel());
+      detachItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.DetachButton.Tooltip"));
+      detachItem.addListener(SWT.Selection, e -> detachExtraView());
+    }
 
     ToolItem closeItem = new ToolItem(extraViewToolBar, SWT.PUSH);
     closeItem.setImage(GuiResource.getInstance().getImageClose());
@@ -4811,9 +4854,84 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     int height = extraViewToolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
     extraViewTabFolder.setTabHeight(Math.max(height, 
extraViewTabFolder.getTabHeight()));
 
-    // Refresh layout for standalone mode
-    sashForm.layout(true, true);
-    sashForm.setWeights(60, 40);
+    if (!detached) {
+      // Refresh layout of the docked sash
+      sashForm.layout(true, true);
+      sashForm.setWeights(60, 40);
+    }
+  }
+
+  /**
+   * Detach the execution-results panel into a floating window that stays 
bound to this pipeline.
+   * The live log sniffer and metrics grid pull from the running pipeline (log 
channel id / engine
+   * metrics), not from their parent widget, so the feed keeps working after 
the move. Implemented
+   * as dispose-and-rebuild to avoid the reparenting fragility of {@code 
Control.setParent()}.
+   */
+  public void detachExtraView() {
+    if (extraViewShell != null || extraViewTabFolder == null || 
extraViewTabFolder.isDisposed()) {
+      return;
+    }
+    int selection = extraViewTabFolder.getSelectionIndex();
+
+    // Tear down the docked folder without flipping the "show results" toolbar 
state.
+    extraViewTabFolder.dispose();
+    extraViewTabFolder = null;
+    sashForm.setWeights(100);
+    sashForm.layout();
+
+    extraViewShell = new Shell(getShell(), SWT.SHELL_TRIM);
+    extraViewShell.setText(
+        BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.Window.Title", getName()));
+    extraViewShell.setImage(GuiResource.getInstance().getImagePipeline());
+    extraViewShell.setLayout(new FormLayout());
+
+    addExtraView(extraViewShell);
+    addAllTabs();
+    selectExtraViewTab(selection);
+
+    // The window's close box re-docks the panel rather than destroying it.
+    extraViewShell.addListener(
+        SWT.Close,
+        e -> {
+          e.doit = false;
+          dockExtraView();
+        });
+
+    extraViewShell.setSize(900, 400);
+    extraViewShell.setLocation(getShell().getLocation().x + 60, 
getShell().getLocation().y + 60);
+    extraViewShell.open();
+  }
+
+  /** Re-dock the floating execution-results panel back into the pipeline's 
sash. */
+  public void dockExtraView() {
+    if (extraViewShell == null) {
+      return;
+    }
+    int selection = extraViewTabFolder == null ? 0 : 
extraViewTabFolder.getSelectionIndex();
+
+    if (extraViewTabFolder != null && !extraViewTabFolder.isDisposed()) {
+      extraViewTabFolder.dispose();
+    }
+    extraViewTabFolder = null;
+
+    Shell shell = extraViewShell;
+    extraViewShell = null;
+    if (!shell.isDisposed()) {
+      shell.dispose();
+    }
+
+    addExtraView(sashForm);
+    addAllTabs();
+    selectExtraViewTab(selection);
+  }
+
+  private void selectExtraViewTab(int index) {
+    if (extraViewTabFolder != null
+        && !extraViewTabFolder.isDisposed()
+        && extraViewTabFolder.getItemCount() > 0) {
+      extraViewTabFolder.setSelection(
+          Math.max(0, Math.min(index, extraViewTabFolder.getItemCount() - 1)));
+    }
   }
 
   public synchronized void start(PipelineExecutionConfiguration 
executionConfiguration)
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 1efd2fe44c..7eb0eaab80 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -334,6 +334,12 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
   public CTabFolder extraViewTabFolder;
 
+  /**
+   * When the execution results (log/metrics) panel is detached into its own 
floating window, this
+   * holds that window; it is {@code null} while the panel is docked in {@link 
#sashForm}.
+   */
+  private Shell extraViewShell;
+
   private Control toolBar;
   @Getter private GuiToolbarWidgets toolBarWidgets;
 
@@ -3968,10 +3974,23 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
   /** Add an extra view to the main composite SashForm */
   public void addExtraView() {
+    addExtraView(sashForm);
+  }
+
+  /**
+   * Build the execution-results tab folder inside the given parent. When 
{@code parent} is the
+   * workflow's own {@link #sashForm} the panel is docked (with min/max, 
rotate and detach
+   * controls); otherwise it is being hosted in a floating window (with a dock 
control instead). The
+   * delegates always (re)build their tabs into the {@link 
#extraViewTabFolder} field, so switching
+   * parents is a dispose-and-rebuild rather than an SWT reparent.
+   *
+   * @param parent the composite to host the tab folder (the sash when docked, 
a floating shell
+   *     otherwise)
+   */
+  public void addExtraView(Composite parent) {
+    boolean detached = parent != sashForm;
 
-    // Always use standalone mode - execution results render in workflow's own 
sashForm
-    // Add a tab folder in the workflow's sashForm
-    extraViewTabFolder = new CTabFolder(sashForm, SWT.MULTI);
+    extraViewTabFolder = new CTabFolder(parent, SWT.MULTI);
     PropsUi.setLook(extraViewTabFolder, Props.WIDGET_STYLE_TAB);
 
     // Layout the tab folder to fill its parent
@@ -3982,39 +4001,56 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     fdTabFolder.bottom = new FormAttachment(100, 0);
     extraViewTabFolder.setLayoutData(fdTabFolder);
 
-    extraViewTabFolder.addMouseListener(
-        new MouseAdapter() {
+    // Double-click to maximize only makes sense while docked in the sash.
+    if (!detached) {
+      extraViewTabFolder.addMouseListener(
+          new MouseAdapter() {
 
-          @Override
-          public void mouseDoubleClick(MouseEvent arg0) {
-            if (sashForm.getMaximizedControl() == null) {
-              sashForm.setMaximizedControl(extraViewTabFolder);
-            } else {
-              sashForm.setMaximizedControl(null);
+            @Override
+            public void mouseDoubleClick(MouseEvent arg0) {
+              if (sashForm.getMaximizedControl() == null) {
+                sashForm.setMaximizedControl(extraViewTabFolder);
+              } else {
+                sashForm.setMaximizedControl(null);
+              }
             }
-          }
-        });
+          });
+    }
 
-    // Create toolbar for close and min/max to the upper right corner...
+    // Create toolbar for the panel controls in the upper right corner...
     //
     ToolBar extraViewToolBar = new ToolBar(extraViewTabFolder, SWT.FLAT);
     extraViewTabFolder.setTopRight(extraViewToolBar, SWT.RIGHT);
     PropsUi.setLook(extraViewToolBar);
 
-    minMaxItem = new ToolItem(extraViewToolBar, SWT.PUSH);
-    minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
-    minMaxItem.setToolTipText(
-        BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
-    minMaxItem.addListener(SWT.Selection, e -> minMaxExtraView());
-
-    rotateItem = new ToolItem(extraViewToolBar, SWT.PUSH);
-    rotateItem.setImage(
-        PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
-            ? GuiResource.getInstance().getImageRotateRight()
-            : GuiResource.getInstance().getImageRotateLeft());
-    rotateItem.setToolTipText(
-        BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.RotateButton.Tooltip"));
-    rotateItem.addListener(SWT.Selection, e -> rotateExtraView());
+    if (detached) {
+      ToolItem dockItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      dockItem.setImage(GuiResource.getInstance().getImageDockPanel());
+      dockItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.DockButton.Tooltip"));
+      dockItem.addListener(SWT.Selection, e -> dockExtraView());
+    } else {
+      minMaxItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
+      minMaxItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
+      minMaxItem.addListener(SWT.Selection, e -> minMaxExtraView());
+
+      rotateItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      rotateItem.setImage(
+          PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
+              ? GuiResource.getInstance().getImageRotateRight()
+              : GuiResource.getInstance().getImageRotateLeft());
+      rotateItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.RotateButton.Tooltip"));
+      rotateItem.addListener(SWT.Selection, e -> rotateExtraView());
+
+      ToolItem detachItem = new ToolItem(extraViewToolBar, SWT.PUSH);
+      detachItem.setImage(GuiResource.getInstance().getImageDetachPanel());
+      detachItem.setToolTipText(
+          BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.DetachButton.Tooltip"));
+      detachItem.addListener(SWT.Selection, e -> detachExtraView());
+    }
 
     ToolItem closeItem = new ToolItem(extraViewToolBar, SWT.PUSH);
     closeItem.setImage(GuiResource.getInstance().getImageClose());
@@ -4025,9 +4061,84 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     int height = extraViewToolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
     extraViewTabFolder.setTabHeight(Math.max(height, 
extraViewTabFolder.getTabHeight()));
 
-    // Refresh layout for standalone mode
-    sashForm.layout(true, true);
-    sashForm.setWeights(new int[] {60, 40});
+    if (!detached) {
+      // Refresh layout of the docked sash
+      sashForm.layout(true, true);
+      sashForm.setWeights(new int[] {60, 40});
+    }
+  }
+
+  /**
+   * Detach the execution-results panel into a floating window that stays 
bound to this workflow.
+   * The live log sniffer and metrics grid pull from the running workflow (log 
channel id / engine
+   * metrics), not from their parent widget, so the feed keeps working after 
the move. Implemented
+   * as dispose-and-rebuild to avoid the reparenting fragility of {@code 
Control.setParent()}.
+   */
+  public void detachExtraView() {
+    if (extraViewShell != null || extraViewTabFolder == null || 
extraViewTabFolder.isDisposed()) {
+      return;
+    }
+    int selection = extraViewTabFolder.getSelectionIndex();
+
+    // Tear down the docked folder without flipping the "show results" toolbar 
state.
+    extraViewTabFolder.dispose();
+    extraViewTabFolder = null;
+    sashForm.setWeights(100);
+    sashForm.layout();
+
+    extraViewShell = new Shell(getShell(), SWT.SHELL_TRIM);
+    extraViewShell.setText(
+        BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.Window.Title", getName()));
+    extraViewShell.setImage(GuiResource.getInstance().getImageWorkflow());
+    extraViewShell.setLayout(new FormLayout());
+
+    addExtraView(extraViewShell);
+    addAllTabs();
+    selectExtraViewTab(selection);
+
+    // The window's close box re-docks the panel rather than destroying it.
+    extraViewShell.addListener(
+        SWT.Close,
+        e -> {
+          e.doit = false;
+          dockExtraView();
+        });
+
+    extraViewShell.setSize(900, 400);
+    extraViewShell.setLocation(getShell().getLocation().x + 60, 
getShell().getLocation().y + 60);
+    extraViewShell.open();
+  }
+
+  /** Re-dock the floating execution-results panel back into the workflow's 
sash. */
+  public void dockExtraView() {
+    if (extraViewShell == null) {
+      return;
+    }
+    int selection = extraViewTabFolder == null ? 0 : 
extraViewTabFolder.getSelectionIndex();
+
+    if (extraViewTabFolder != null && !extraViewTabFolder.isDisposed()) {
+      extraViewTabFolder.dispose();
+    }
+    extraViewTabFolder = null;
+
+    Shell shell = extraViewShell;
+    extraViewShell = null;
+    if (!shell.isDisposed()) {
+      shell.dispose();
+    }
+
+    addExtraView(sashForm);
+    addAllTabs();
+    selectExtraViewTab(selection);
+  }
+
+  private void selectExtraViewTab(int index) {
+    if (extraViewTabFolder != null
+        && !extraViewTabFolder.isDisposed()
+        && extraViewTabFolder.getItemCount() > 0) {
+      extraViewTabFolder.setSelection(
+          Math.max(0, Math.min(index, extraViewTabFolder.getItemCount() - 1)));
+    }
   }
 
   @GuiToolbarElement(
@@ -4075,6 +4186,13 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     extraViewTabFolder.dispose();
     extraViewTabFolder = null;
 
+    // If the panel was floating, close its window too.
+    if (extraViewShell != null && !extraViewShell.isDisposed()) {
+      Shell shell = extraViewShell;
+      extraViewShell = null;
+      shell.dispose();
+    }
+
     sashForm.layout();
     sashForm.setWeights(100);
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java
index d1a4d6b017..3d5c84836b 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java
@@ -148,6 +148,34 @@ public interface IHopPerspective extends 
IActionContextHandlersProvider {
     // Do nothing by default
   }
 
+  /** Drop into the folder itself (no split). */
+  int DROP_ZONE_CENTER = 0;
+
+  /** Split the folder and drop into a new pane above it. */
+  int DROP_ZONE_NORTH = 1;
+
+  /** Split the folder and drop into a new pane below it. */
+  int DROP_ZONE_SOUTH = 2;
+
+  /** Split the folder and drop into a new pane to its left. */
+  int DROP_ZONE_WEST = 3;
+
+  /** Split the folder and drop into a new pane to its right. */
+  int DROP_ZONE_EAST = 4;
+
+  /**
+   * Resolve the folder that should receive a tab dropped in the given zone of 
{@code targetFolder}.
+   * For an edge zone a perspective may create a new split pane and return it; 
the default just
+   * returns the target folder (drop into it, no split).
+   *
+   * @param targetFolder the folder under the cursor
+   * @param zone one of the {@code DROP_ZONE_*} constants
+   * @return the folder that should receive the dropped tab (never {@code 
null})
+   */
+  default CTabFolder resolveDropFolderForZone(CTabFolder targetFolder, int 
zone) {
+    return targetFolder;
+  }
+
   /** Clear search/filter text fields when a new project is activated. */
   default void clearSearchFilters() {
     // Do nothing by default
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java
index dd7e2935e6..5ccb057069 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java
@@ -18,6 +18,7 @@
 package org.apache.hop.ui.hopgui.perspective;
 
 import java.nio.charset.Charset;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.util.EnvironmentUtils;
@@ -40,23 +41,71 @@ import org.eclipse.swt.graphics.GC;
 import org.eclipse.swt.graphics.Image;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.graphics.Region;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Listener;
+import org.eclipse.swt.widgets.Shell;
 
 public class TabItemReorder {
+
+  /** Fraction of the folder width/height near an edge that triggers a 
split-on-drop. */
+  private static final double EDGE_FRACTION = 0.3;
+
   private final IHopPerspective perspective;
   private CTabItem dragItem;
 
+  /**
+   * Tab the pointer last went down on, captured on {@code SWT.MouseDown} 
(which always precedes a
+   * drag). {@code dragStart} uses this instead of {@link 
Display#getCursorLocation()}, which reads
+   * stale on the first native drag of a macOS session — leaving {@code 
dragItem} null and the first
+   * drop a silent no-op until a second try.
+   */
+  private CTabItem mouseDownItem;
+
   /**
    * Tab under the cursor during a tab drag; drop will swap with this tab. 
Painted as drop
    * indicator.
    */
   private CTabItem dropTargetTab;
 
+  /** Current drop zone under the cursor (one of {@link IHopPerspective}'s 
{@code DROP_ZONE_*}). */
+  private int dropZone = IHopPerspective.DROP_ZONE_CENTER;
+
+  /**
+   * Last zone computed while genuinely dragging over the folder (updated in 
{@code dragOver}). Not
+   * reset by {@code dragLeave}, so {@code drop} can fall back to it when the 
drop event's own
+   * coordinates come through degenerate (seen on the first macOS drag of a 
session).
+   */
+  private int lastDragOverZone = IHopPerspective.DROP_ZONE_CENTER;
+
+  /**
+   * Overlay marking where a split-on-drop would land; a hollow frame (see 
{@link #overlayRegion})
+   * so the cursor passes through its centre to the folder underneath instead 
of stealing the drag.
+   */
+  private Shell zoneOverlay;
+
+  /** The frame-shaped region applied to {@link #zoneOverlay}; disposed with 
it. */
+  private Region overlayRegion;
+
+  /** Zone/folder the overlay currently reflects, so we only touch the Shell 
when they change. */
+  private int shownZone = IHopPerspective.DROP_ZONE_CENTER;
+
+  private CTabFolder shownFolder;
+
   public TabItemReorder(IHopPerspective perspective, CTabFolder folder) {
     this.perspective = perspective;
 
+    // Remember which tab the pointer went down on: dragStart can't reliably 
re-derive it from the
+    // cursor location on the first macOS drag of a session.
+    folder.addListener(
+        SWT.MouseDown,
+        e -> {
+          if (e.button == 1) {
+            mouseDownItem = folder.getItem(new Point(e.x, e.y));
+          }
+        });
+
     final DragSource source = new DragSource(folder, DND.DROP_MOVE);
     source.setTransfer(TabTransfer.INSTANCE);
     source.addDragListener(
@@ -65,10 +114,12 @@ public class TabItemReorder {
 
           @Override
           public void dragStart(DragSourceEvent event) {
-            Point point = 
folder.toControl(folder.getDisplay().getCursorLocation());
-            dragItem = folder.getItem(point);
+            dragItem = itemBeingDragged(folder);
 
             if (dragItem == null) {
+              // Couldn't identify the tab (e.g. drag not started from a tab): 
cancel cleanly rather
+              // than begin a data-less drag that would silently do nothing on 
drop.
+              event.doit = false;
               return;
             }
             Rectangle columnBounds = dragItem.getBounds();
@@ -145,6 +196,7 @@ public class TabItemReorder {
 
           @Override
           public void dragEnter(DropTargetEvent event) {
+            lastDragOverZone = IHopPerspective.DROP_ZONE_CENTER;
             isFileDrop = isFileTransferType(event);
             isMetadataDrop = isMetadataTransferType(event);
             if (isFileDrop) {
@@ -162,10 +214,7 @@ public class TabItemReorder {
           @Override
           public void dragLeave(DropTargetEvent event) {
             handleDragEvent(event);
-            if (dropTargetTab != null) {
-              dropTargetTab = null;
-              folder.redraw();
-            }
+            clearDropFeedback(folder);
           }
 
           @Override
@@ -197,28 +246,49 @@ public class TabItemReorder {
               event.detail = DND.DROP_MOVE;
             }
             handleDragEvent(event);
-            // Update drop indicator for tab reorder
-            if (!isFileDrop && dragItem != null && event.detail != 
DND.DROP_NONE) {
-              Point p = 
folder.toControl(folder.getDisplay().getCursorLocation());
+            // Update drop indicator (tab reorder) and split zone (edge drop) 
feedback.
+            boolean tabDrag =
+                !isFileDrop && !isMetadataDrop && (dragItem != null || 
hasActiveTabTransfer(event));
+            if (tabDrag && event.detail != DND.DROP_NONE) {
+              Point p = eventPoint(folder, event);
               CTabItem over = folder.getItem(p);
               CTabItem newTarget = (over != null && over != dragItem) ? over : 
null;
               if (newTarget != dropTargetTab) {
                 dropTargetTab = newTarget;
                 folder.redraw();
               }
-            } else if (dropTargetTab != null) {
-              dropTargetTab = null;
-              folder.redraw();
+              // Over the tab strip = reorder/insert (center); elsewhere = 
possible edge split.
+              int newZone =
+                  (over != null) ? IHopPerspective.DROP_ZONE_CENTER : 
computeDropZone(folder, p);
+              // Don't advertise a split that would be a no-op: dragging the 
sole tab of this folder
+              // to its own edge can't split (Hop keeps one tab per file).
+              if (newZone != IHopPerspective.DROP_ZONE_CENTER
+                  && dragItem != null
+                  && dragItem.getParent() == folder
+                  && folder.getItemCount() <= 1) {
+                newZone = IHopPerspective.DROP_ZONE_CENTER;
+              }
+              dropZone = newZone;
+              // Remember the zone while we're genuinely over the folder; 
drop() falls back to this
+              // if its own event coordinates come through degenerate.
+              lastDragOverZone = newZone;
+              updateZoneOverlay(folder);
+            } else {
+              clearDropFeedback(folder);
             }
           }
 
           @Override
           public void drop(DropTargetEvent event) {
             handleDragEvent(event);
-            if (dropTargetTab != null) {
-              dropTargetTab = null;
-              folder.redraw();
-            }
+            // Resolve the drop point from the event's own display 
coordinates, which — unlike
+            // Display.getCursorLocation() — are the actual drop location and 
are reliable even on
+            // the first macOS drag of a session (where getCursorLocation() 
can read stale, making a
+            // split silently no-op and needing a second drop). Fall back to 
the last drag-over zone
+            // only when the event point is degenerate (outside the folder).
+            Point dropPoint = eventPoint(folder, event);
+            int zone = resolveDropZone(folder, dropPoint);
+            clearDropFeedback(folder);
             if (isMetadataTransferType(event)
                 && event.data instanceof String[] metadataData
                 && metadataData.length >= 2
@@ -232,8 +302,30 @@ public class TabItemReorder {
               receiver.openDroppedFiles(paths);
               return;
             }
+            boolean tabDrag = dragItem != null || hasActiveTabTransfer(event);
+            if (LogChannel.UI.isDebug() && tabDrag) {
+              LogChannel.UI.logDebug(
+                  "Tab drop: detail="
+                      + event.detail
+                      + " zone="
+                      + zone
+                      + " point="
+                      + dropPoint
+                      + " dragItem="
+                      + (dragItem == null ? "null" : dragItem.getText())
+                      + " targetItems="
+                      + folder.getItemCount());
+            }
             if (event.detail == DND.DROP_MOVE) {
-              moveTabs(folder, event);
+              moveTabs(folder, event, zone, dropPoint);
+            } else if (tabDrag && isUnambiguousTabDrop(folder, zone, 
dropPoint)) {
+              // detail resolved to something other than MOVE (seen 
intermittently on the first
+              // macOS drag). When the drop is unambiguous — an edge split, or 
a move from another
+              // folder — complete it rather than silently dropping it. 
Ambiguous same-folder centre
+              // drops are left alone to avoid a spurious self-swap.
+              LogChannel.UI.logDebug(
+                  "Tab drop arrived with detail=" + event.detail + "; 
completing unambiguous move");
+              moveTabs(folder, event, zone, dropPoint);
             }
           }
 
@@ -316,8 +408,18 @@ public class TabItemReorder {
 
           private boolean isDropSupported(CTabFolder folder, DropTargetEvent 
event) {
             if (dragItem != null && !dragItem.isDisposed()) {
-              Point point = 
folder.toControl(folder.getDisplay().getCursorLocation());
-              return folder.getItem(new Point(point.x, point.y)) != null;
+              // Use the event's own coordinates rather than 
Display.getCursorLocation(): the latter
+              // can read stale on the first macOS drag, wrongly collapsing an 
edge drop to CENTER
+              // and forcing event.detail to DROP_NONE (the "first drop does 
nothing" bug).
+              Point point = eventPoint(folder, event);
+              if (folder.getItem(point) != null) {
+                return true;
+              }
+              // Allow an edge drop to split. A same-folder split must leave a 
tab behind (>1 tab);
+              // a cross-folder drop can always land, so only require an edge 
zone there.
+              boolean sameFolder = dragItem.getParent() == folder;
+              boolean edge = computeDropZone(folder, point) != 
IHopPerspective.DROP_ZONE_CENTER;
+              return edge && (!sameFolder || folder.getItemCount() > 1);
             }
             return hasActiveTabTransfer(event);
           }
@@ -360,7 +462,22 @@ public class TabItemReorder {
         });
   }
 
-  private void moveTabs(CTabFolder folder, DropTargetEvent event) {
+  /**
+   * The tab being dragged from {@code folder}. Prefers the tab captured on 
mouse-down (reliable on
+   * every drag, including the first macOS drag); falls back to the cursor 
location only, which
+   * returns a tab solely when the pointer is genuinely over one — so a drag 
begun in the empty body
+   * resolves to null (and is cancelled) rather than grabbing the selected tab.
+   */
+  private CTabItem itemBeingDragged(CTabFolder folder) {
+    if (mouseDownItem != null
+        && !mouseDownItem.isDisposed()
+        && mouseDownItem.getParent() == folder) {
+      return mouseDownItem;
+    }
+    return 
folder.getItem(folder.toControl(folder.getDisplay().getCursorLocation()));
+  }
+
+  private void moveTabs(CTabFolder folder, DropTargetEvent event, int zone, 
Point dropPoint) {
     CTabItem sourceItem = this.dragItem;
 
     if (sourceItem == null || sourceItem.isDisposed()) {
@@ -377,14 +494,24 @@ public class TabItemReorder {
       return;
     }
 
+    // Edge-zone drop: ask the perspective to split the target folder and move 
into the new pane.
+    // Skip when the source is the sole tab of the target folder (splitting 
would be a no-op).
+    if (zone != IHopPerspective.DROP_ZONE_CENTER
+        && !(sourceItem.getParent() == folder && folder.getItemCount() <= 1)) {
+      CTabFolder dest = perspective.resolveDropFolderForZone(folder, zone);
+      if (dest != null && !dest.isDisposed() && dest != 
sourceItem.getParent()) {
+        moveTabBetweenFolders(sourceItem, dest);
+        return;
+      }
+    }
+
     if (sourceItem.getParent() != folder) {
       moveTabBetweenFolders(sourceItem, folder);
       return;
     }
 
-    Point point = folder.toControl(folder.getDisplay().getCursorLocation());
-    CTabItem dropItem = folder.getItem(new Point(point.x, point.y));
-    if (dropItem != null) {
+    CTabItem dropItem = folder.getItem(dropPoint);
+    if (dropItem != null && dropItem != sourceItem) {
       Control dragControl = sourceItem.getControl();
       String dragText = sourceItem.getText();
       Image dragImage = sourceItem.getImage();
@@ -446,6 +573,203 @@ public class TabItemReorder {
     perspective.onTabMovedBetweenFolders(srcFolder, dstFolder);
   }
 
+  /**
+   * The drop point in folder-local coordinates, taken from the drop-target 
event's own display
+   * coordinates ({@code event.x}/{@code event.y}). These are the actual drop 
location and stay
+   * accurate even when {@link Display#getCursorLocation()} reads stale (first 
macOS drag of a
+   * session), which is what made the first split-drop silently do nothing.
+   */
+  private Point eventPoint(CTabFolder folder, DropTargetEvent event) {
+    return folder.toControl(event.x, event.y);
+  }
+
+  /**
+   * The drop zone for a point: over a tab = CENTER, otherwise the edge band. 
Falls back to the last
+   * zone shown during {@code dragOver} only when the point is degenerate 
(outside the folder), so a
+   * genuine centre drop is never turned into a split.
+   */
+  private int resolveDropZone(CTabFolder folder, Point p) {
+    Point size = folder.getSize();
+    boolean inside = p.x >= 0 && p.y >= 0 && p.x <= size.x && p.y <= size.y;
+    if (!inside) {
+      return lastDragOverZone;
+    }
+    if (folder.getItem(p) != null) {
+      return IHopPerspective.DROP_ZONE_CENTER;
+    }
+    return computeDropZone(folder, p);
+  }
+
+  /**
+   * Whether a tab drop is unambiguous enough to complete even when the DnD 
operation resolved to
+   * something other than {@code DROP_MOVE} (an intermittent first-macOS-drag 
quirk): an edge split,
+   * or a drop landing over a real (different) tab. Same-folder centre drops 
with nothing under the
+   * cursor are treated as ambiguous and left alone.
+   */
+  private boolean isUnambiguousTabDrop(CTabFolder folder, int zone, Point 
dropPoint) {
+    if (zone != IHopPerspective.DROP_ZONE_CENTER) {
+      return true;
+    }
+    CTabItem over = folder.getItem(dropPoint);
+    return over != null && over != dragItem;
+  }
+
+  /**
+   * Determine which drop zone the point falls in: the outer {@link 
#EDGE_FRACTION} band on each
+   * side maps to that edge (split), the middle maps to {@code CENTER} (drop 
into the folder as-is).
+   */
+  private int computeDropZone(CTabFolder folder, Point p) {
+    // Drag-to-split relies on native DnD + floating overlays, which don't 
behave under RAP, so on
+    // the web every drop is a plain centre drop (no edge splits). This is the 
single choke point
+    // for edge zones (drag feedback, drop routing and isDropSupported all go 
through here).
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      return IHopPerspective.DROP_ZONE_CENTER;
+    }
+    Point size = folder.getSize();
+    if (size.x <= 0 || size.y <= 0) {
+      return IHopPerspective.DROP_ZONE_CENTER;
+    }
+    double fx = p.x / (double) size.x;
+    double fy = p.y / (double) size.y;
+    if (fx < 0 || fx > 1 || fy < 0 || fy > 1) {
+      return IHopPerspective.DROP_ZONE_CENTER;
+    }
+    double left = fx;
+    double right = 1 - fx;
+    double top = fy;
+    double bottom = 1 - fy;
+    double min = Math.min(Math.min(left, right), Math.min(top, bottom));
+    if (min > EDGE_FRACTION) {
+      return IHopPerspective.DROP_ZONE_CENTER;
+    }
+    if (min == left) {
+      return IHopPerspective.DROP_ZONE_WEST;
+    }
+    if (min == right) {
+      return IHopPerspective.DROP_ZONE_EAST;
+    }
+    if (min == top) {
+      return IHopPerspective.DROP_ZONE_NORTH;
+    }
+    return IHopPerspective.DROP_ZONE_SOUTH;
+  }
+
+  /** Reset all drop feedback: tab-swap highlight and the split-zone overlay. 
*/
+  private void clearDropFeedback(CTabFolder folder) {
+    if (dropTargetTab != null) {
+      dropTargetTab = null;
+      if (!folder.isDisposed()) {
+        folder.redraw();
+      }
+    }
+    dropZone = IHopPerspective.DROP_ZONE_CENTER;
+    hideZoneOverlay();
+  }
+
+  /** Show (or move) the translucent overlay marking where an edge-drop split 
would land. */
+  private void updateZoneOverlay(CTabFolder folder) {
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      return; // Floating overlays have no faithful equivalent under RAP.
+    }
+    if (dropZone == IHopPerspective.DROP_ZONE_CENTER || folder.isDisposed()) {
+      hideZoneOverlay();
+      return;
+    }
+    // Nothing changed since the overlay was last shown: leave the Shell 
untouched. Repositioning it
+    // on every drag-over event (they fire continuously) is what makes it 
flicker.
+    if (dropZone == shownZone
+        && folder == shownFolder
+        && zoneOverlay != null
+        && !zoneOverlay.isDisposed()
+        && zoneOverlay.getVisible()) {
+      return;
+    }
+    Rectangle r = zoneRectangleDisplay(folder, dropZone);
+    if (r == null || r.width <= 0 || r.height <= 0) {
+      hideZoneOverlay();
+      return;
+    }
+    try {
+      if (zoneOverlay == null || zoneOverlay.isDisposed()) {
+        zoneOverlay = new Shell(folder.getShell(), SWT.NO_TRIM | SWT.ON_TOP);
+        
zoneOverlay.setBackground(folder.getDisplay().getSystemColor(SWT.COLOR_LIST_SELECTION));
+        zoneOverlay.addDisposeListener(e -> disposeOverlayRegion());
+      }
+      zoneOverlay.setBounds(r);
+      applyFrameRegion(r.width, r.height);
+      if (!zoneOverlay.getVisible()) {
+        zoneOverlay.setVisible(true);
+      }
+      shownZone = dropZone;
+      shownFolder = folder;
+    } catch (Exception e) {
+      hideZoneOverlay();
+    }
+  }
+
+  /**
+   * Shape {@link #zoneOverlay} as a hollow rectangle frame of the given size. 
The cut-out centre is
+   * not part of the window, so the drag cursor passes through it to the 
folder underneath (no
+   * enter/leave oscillation), and only the thin border is painted (no 
compositing flicker).
+   */
+  private void applyFrameRegion(int width, int height) {
+    int border = Math.max(3, Math.min(8, Math.min(width, height) / 12));
+    Region region = new Region(zoneOverlay.getDisplay());
+    region.add(0, 0, width, height);
+    if (width > 2 * border && height > 2 * border) {
+      region.subtract(border, border, width - 2 * border, height - 2 * border);
+    }
+    zoneOverlay.setRegion(region);
+    disposeOverlayRegion();
+    overlayRegion = region;
+  }
+
+  private void disposeOverlayRegion() {
+    if (overlayRegion != null && !overlayRegion.isDisposed()) {
+      overlayRegion.dispose();
+    }
+    overlayRegion = null;
+  }
+
+  private void hideZoneOverlay() {
+    if (zoneOverlay != null && !zoneOverlay.isDisposed() && 
zoneOverlay.getVisible()) {
+      zoneOverlay.setVisible(false);
+    }
+    shownZone = IHopPerspective.DROP_ZONE_CENTER;
+    shownFolder = null;
+  }
+
+  /**
+   * The half of the folder (in display coordinates) that a split-drop in 
{@code zone} would use.
+   */
+  private Rectangle zoneRectangleDisplay(CTabFolder folder, int zone) {
+    Point size = folder.getSize();
+    if (size.x <= 0 || size.y <= 0) {
+      return null;
+    }
+    int x = 0;
+    int y = 0;
+    int w = size.x;
+    int h = size.y;
+    switch (zone) {
+      case IHopPerspective.DROP_ZONE_WEST -> w = size.x / 2;
+      case IHopPerspective.DROP_ZONE_EAST -> {
+        x = size.x / 2;
+        w = size.x - x;
+      }
+      case IHopPerspective.DROP_ZONE_NORTH -> h = size.y / 2;
+      case IHopPerspective.DROP_ZONE_SOUTH -> {
+        y = size.y / 2;
+        h = size.y - y;
+      }
+      default -> {
+        return null;
+      }
+    }
+    Point topLeft = folder.toDisplay(x, y);
+    return new Rectangle(topLeft.x, topLeft.y, w, h);
+  }
+
   private void updateTabItemHandler(IHopFileTypeHandler fileTypeHandler, 
CTabItem tabItem) {
     for (TabItemHandler item : perspective.getItems()) {
       if (fileTypeHandler.equals(item.getTypeHandler())) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 072e2d1f04..711d184f89 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -223,12 +223,24 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   private static final String FILE_EXPLORER_TREE = "File explorer tree";
   private static final String TREE_WIDTH_AUDIT_TYPE = 
"explorer-perspective-tree-width";
   private static final String EXPLORER_AUDIT_TYPE = 
"explorer-perspective-state";
+
+  /** State key under {@link #EXPLORER_AUDIT_TYPE} holding the serialized 
editor layout tree. */
+  private static final String STATE_EDITOR_LAYOUT_KEY = "editor-layout";
+
+  private static final String STATE_EDITOR_LAYOUT_ROOT = "root";
+
+  // Serialized layout-node fields (kept short: the blob is written per 
shutdown).
+  private static final String LAYOUT_TYPE = "t";
+  private static final String LAYOUT_TYPE_SPLIT = "split";
+  private static final String LAYOUT_TYPE_LEAF = "leaf";
+  private static final String LAYOUT_ORIENTATION = "o";
+  private static final String LAYOUT_ORIENTATION_VERTICAL = "V";
+  private static final String LAYOUT_ORIENTATION_HORIZONTAL = "H";
+  private static final String LAYOUT_WEIGHTS = "w";
+  private static final String LAYOUT_CHILDREN = "c";
+  private static final String LAYOUT_LEAF_ID = "id";
   private static final String STATE_PANEL_VISIBLE_KEY = "panel-visible";
   private static final String STATE_PANEL_VISIBLE_PROP = "visible";
-  private static final String STATE_EDITOR_SPLIT_KEY = "editor-split";
-  private static final String STATE_EDITOR_SPLIT_PROP = "split";
-  private static final String STATE_EDITOR_SASH_WEIGHTS_KEY = 
"editor-sash-weights";
-  private static final String STATE_EDITOR_SASH_WEIGHTS_PROP = "weights";
   private static final String KEY_TAB_FOLDER = "hop-explorer-tabFolder";
 
   private static ExplorerPerspective instance;
@@ -242,11 +254,30 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   private SashForm sash;
   @Getter private Tree tree;
   private TreeEditor treeEditor;
-  private CTabFolder tabFolder;
-  private CTabFolder tabFolder2;
-  private SashForm editorSash;
+
+  /**
+   * Root of the recursive editor layout tree. A leaf is a {@link CTabFolder}; 
an internal node is a
+   * {@link SashForm} whose (non-{@link org.eclipse.swt.widgets.Sash}) 
children are themselves
+   * leaves or nested SashForms. When there is a single pane, {@code 
editorRoot} is that one
+   * CTabFolder.
+   */
+  private Control editorRoot;
+
+  /**
+   * Leaf tab folders that live in their own floating windows (detached editor 
tabs). They are not
+   * part of {@link #editorRoot} but do participate in {@link 
#getTabFolders()} so that active-file
+   * tracking, save/close and drag-and-drop work across windows.
+   */
+  private final List<CTabFolder> detachedFolders = new ArrayList<>();
+
+  /**
+   * Maps a persisted leaf id ("L0", "L1", ... in tree pre-order) to the 
docked tab folder rebuilt
+   * for it during {@link #applyRestoredEditorSplitState()}. Used by audit 
restore to route each
+   * reopened file into the pane it was saved in. Rebuilt on every restore.
+   */
+  private final Map<String, CTabFolder> folderByLeafId = new HashMap<>();
+
   private CTabFolder activeTabFolder;
-  private boolean editorSplit;
   private Composite tabFolderWrapper;
   private Control toolBar;
   @Getter private GuiMenuWidgets menuWidgets;
@@ -397,7 +428,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
               Control c = focusControl;
               while (c != null) {
                 Object data = c.getData(KEY_TAB_FOLDER);
-                if (data == tabFolder || data == tabFolder2) {
+                if (data instanceof CTabFolder && isEditorTabFolder((Control) 
data)) {
                   CTabFolder folder = (CTabFolder) data;
                   for (CTabItem item : folder.getItems()) {
                     if (item.getControl() == c) {
@@ -1772,14 +1803,9 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     tabFolderWrapper.setLayoutData(new FormDataBuilder().fullSize().result());
     PropsUi.setLook(tabFolderWrapper);
 
-    editorSash = new SashForm(tabFolderWrapper, SWT.HORIZONTAL);
-    editorSash.setLayoutData(new FormDataBuilder().fullSize().result());
-
-    tabFolder = createSingleTabFolder(editorSash, true);
-    tabFolder2 = createSingleTabFolder(editorSash, false);
-
-    activeTabFolder = tabFolder;
-    editorSash.setMaximizedControl(tabFolder);
+    CTabFolder rootTabFolder = createSingleTabFolder(tabFolderWrapper, true);
+    editorRoot = rootTabFolder;
+    activeTabFolder = rootTabFolder;
   }
 
   private CTabFolder createSingleTabFolder(Composite parent, boolean primary) {
@@ -1839,44 +1865,61 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     new TabCloseHandler(this, folder);
     new TabItemReorder(this, folder);
 
-    Menu menu = folder.getMenu();
-    new MenuItem(menu, SWT.SEPARATOR);
-    MenuItem miSplitMove = new MenuItem(menu, SWT.NONE);
-    miSplitMove.setText(BaseMessages.getString(PKG, 
"ExplorerPerspective.TabMenu.MoveToRight"));
+    // Split ("Move to Right") and detach ("Move to New Window") depend on 
native drag and floating
+    // windows, which don't work under RAP, so they are desktop-only (see also 
the drag-to-split
+    // guard in TabItemReorder and the isWeb() overlay gating).
+    if (!EnvironmentUtils.getInstance().isWeb()) {
+      Menu menu = folder.getMenu();
+      new MenuItem(menu, SWT.SEPARATOR);
+      MenuItem miSplitMove = new MenuItem(menu, SWT.NONE);
+      miSplitMove.setText(BaseMessages.getString(PKG, 
"ExplorerPerspective.TabMenu.MoveToRight"));
+
+      folder.addListener(
+          SWT.MenuDetect,
+          event -> {
+            Point pt = 
folder.toControl(folder.getDisplay().getCursorLocation());
+            splitMenuTargetTab = folder.getItem(new Point(pt.x, pt.y));
+          });
 
-    folder.addListener(
-        SWT.MenuDetect,
-        event -> {
-          Point pt = folder.toControl(folder.getDisplay().getCursorLocation());
-          splitMenuTargetTab = folder.getItem(new Point(pt.x, pt.y));
-        });
+      MenuItem miDetach = new MenuItem(menu, SWT.NONE);
+      miDetach.setText(BaseMessages.getString(PKG, 
"ExplorerPerspective.TabMenu.MoveToNewWindow"));
 
-    menu.addListener(
-        SWT.Show,
-        e -> {
-          if (!editorSplit || folder == tabFolder) {
+      menu.addListener(
+          SWT.Show,
+          e -> {
             miSplitMove.setText(
                 BaseMessages.getString(PKG, 
"ExplorerPerspective.TabMenu.MoveToRight"));
-          } else {
-            miSplitMove.setText(
-                BaseMessages.getString(PKG, 
"ExplorerPerspective.TabMenu.MoveToLeft"));
-          }
-          miSplitMove.setEnabled(splitMenuTargetTab != null);
-        });
+            // Splitting only makes sense if the folder keeps at least one tab 
behind.
+            miSplitMove.setEnabled(splitMenuTargetTab != null && 
folder.getItemCount() > 1);
+            miDetach.setEnabled(splitMenuTargetTab != null);
+          });
 
-    miSplitMove.addListener(
-        SWT.Selection,
-        e -> {
-          if (splitMenuTargetTab != null && !splitMenuTargetTab.isDisposed()) {
-            splitOrMoveTab(splitMenuTargetTab);
-          }
-        });
+      miSplitMove.addListener(
+          SWT.Selection,
+          e -> {
+            if (splitMenuTargetTab != null && 
!splitMenuTargetTab.isDisposed()) {
+              splitOrMoveTab(splitMenuTargetTab);
+            }
+          });
+
+      miDetach.addListener(
+          SWT.Selection,
+          e -> {
+            if (splitMenuTargetTab != null && 
!splitMenuTargetTab.isDisposed()) {
+              detachTabToWindow(splitMenuTargetTab);
+            }
+          });
+    }
 
     return folder;
   }
 
   private CTabFolder getTargetTabFolder() {
-    return activeTabFolder != null ? activeTabFolder : tabFolder;
+    if (activeTabFolder != null && !activeTabFolder.isDisposed()) {
+      return activeTabFolder;
+    }
+    List<CTabFolder> folders = getTabFolders();
+    return folders.isEmpty() ? null : folders.get(0);
   }
 
   @Override
@@ -2074,10 +2117,8 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
     tabItem.dispose();
     if (!hopGui.fileDelegate.isClosing()) {
-      if (editorSplit && (tabFolder.getItemCount() == 0 || 
tabFolder2.getItemCount() == 0)) {
-        unsplitEditor();
-      }
-      if (tabFolder.getItemCount() == 0 && tabFolder2.getItemCount() == 0) {
+      collapseEmptyFolders();
+      if (isEditorEmpty()) {
         HopGui.getInstance().handleFileCapabilities(new EmptyFileType(), 
false, false, false);
       }
       updateGui();
@@ -2100,11 +2141,9 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
     if (!hopGui.fileDelegate.isClosing()) {
 
-      if (editorSplit && (tabFolder.getItemCount() == 0 || 
tabFolder2.getItemCount() == 0)) {
-        unsplitEditor();
-      }
+      collapseEmptyFolders();
 
-      if (tabFolder.getItemCount() == 0 && tabFolder2.getItemCount() == 0) {
+      if (isEditorEmpty()) {
         HopGui.getInstance().handleFileCapabilities(new EmptyFileType(), 
false, false, false);
       }
 
@@ -2118,28 +2157,21 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   }
 
   /**
-   * Returns tab item handlers in pane order: left pane (tabFolder) first by 
tab index, then right
-   * pane (tabFolder2) by tab index. Used when persisting open files so 
restore order matches split
+   * Returns tab item handlers in pane order: leaf folders left-to-right / 
top-to-bottom, each by
+   * tab index. Used when persisting open files so restore order roughly 
matches the on-screen
    * layout.
    */
   public List<TabItemHandler> getTabItemHandlersInPaneOrder() {
     List<TabItemHandler> ordered = new ArrayList<>();
-    if (tabFolder == null || tabFolder2 == null) {
-      return getItems();
-    }
-    for (CTabItem item : tabFolder.getItems()) {
-      TabItemHandler h = findHandlerByTabItem(item);
-      if (h != null) {
-        ordered.add(h);
-      }
-    }
-    for (CTabItem item : tabFolder2.getItems()) {
-      TabItemHandler h = findHandlerByTabItem(item);
-      if (h != null) {
-        ordered.add(h);
+    for (CTabFolder folder : getTabFolders()) {
+      for (CTabItem item : folder.getItems()) {
+        TabItemHandler h = findHandlerByTabItem(item);
+        if (h != null) {
+          ordered.add(h);
+        }
       }
     }
-    return ordered;
+    return ordered.isEmpty() ? getItems() : ordered;
   }
 
   private TabItemHandler findHandlerByTabItem(CTabItem tabItem) {
@@ -2151,17 +2183,30 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     return null;
   }
 
-  /** Pane index for persistence: 0 = left (tabFolder), 1 = right 
(tabFolder2). */
-  public int getPaneIndexForTab(CTabItem tabItem) {
+  /**
+   * The persisted leaf id ("L0", "L1", ... in docked-tree pre-order) of the 
pane holding {@code
+   * tabItem}, or {@code null} for tabs living in a detached floating window 
(those re-dock into the
+   * default pane on restart). Matches the ids produced by {@link 
#serializeEditorLayout()}.
+   */
+  public String getLeafIdForTab(CTabItem tabItem) {
     if (tabItem == null || tabItem.isDisposed()) {
-      return 0;
+      return null;
     }
-    return tabItem.getParent() == tabFolder2 ? 1 : 0;
+    int index = getDockedTabFolders().indexOf(tabItem.getParent());
+    return index >= 0 ? "L" + index : null;
   }
 
-  /** The right-hand tab folder when split; used by audit restore to target 
the correct pane. */
-  public CTabFolder getRightTabFolder() {
-    return tabFolder2;
+  /**
+   * The docked tab folder rebuilt for a persisted leaf id during layout 
restore, or {@code null}
+   * when the id is unknown (e.g. legacy int pane values, or a detached-window 
id) so restore falls
+   * back to the default pane.
+   */
+  public CTabFolder getTabFolderForLeafId(String leafId) {
+    if (leafId == null) {
+      return null;
+    }
+    CTabFolder folder = folderByLeafId.get(leafId);
+    return (folder != null && !folder.isDisposed()) ? folder : null;
   }
 
   public void addFile(IExplorerFileTypeHandler fileTypeHandler) {
@@ -2535,8 +2580,11 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
     int idx = active.getSelectionIndex();
     if (idx < 0 || idx >= active.getItemCount()) {
-      CTabFolder other = (active == tabFolder) ? tabFolder2 : tabFolder;
-      if (other != null && !other.isDisposed()) {
+      // The active pane has no selection: fall back to any other pane that 
does.
+      for (CTabFolder other : getTabFolders()) {
+        if (other == active || other.isDisposed()) {
+          continue;
+        }
         int otherIdx = other.getSelectionIndex();
         if (otherIdx >= 0 && otherIdx < other.getItemCount()) {
           return (IHopFileTypeHandler) other.getSelection().getData();
@@ -2606,8 +2654,208 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     return null;
   }
 
+  /**
+   * All leaf tab folders that hold editor tabs: the docked layout tree first 
(left-to-right /
+   * top-to-bottom), then any detached floating windows.
+   */
   private List<CTabFolder> getTabFolders() {
-    return List.of(tabFolder, tabFolder2);
+    List<CTabFolder> result = new ArrayList<>();
+    collectTabFolders(editorRoot, result);
+    for (CTabFolder folder : detachedFolders) {
+      if (folder != null && !folder.isDisposed()) {
+        result.add(folder);
+      }
+    }
+    return result;
+  }
+
+  /** Leaf folders in the docked layout tree only (excludes detached windows). 
*/
+  private List<CTabFolder> getDockedTabFolders() {
+    List<CTabFolder> result = new ArrayList<>();
+    collectTabFolders(editorRoot, result);
+    return result;
+  }
+
+  private boolean isDockedTabFolder(CTabFolder folder) {
+    return getDockedTabFolders().contains(folder);
+  }
+
+  private void collectTabFolders(Control node, List<CTabFolder> out) {
+    if (node == null || node.isDisposed()) {
+      return;
+    }
+    if (node instanceof CTabFolder folder) {
+      out.add(folder);
+    } else if (node instanceof SashForm sashForm) {
+      for (Control child : sashForm.getChildren()) {
+        // SashForm children include the draggable Sash dividers; only recurse 
into real nodes.
+        if (child instanceof CTabFolder || child instanceof SashForm) {
+          collectTabFolders(child, out);
+        }
+      }
+    }
+  }
+
+  /** The node children (leaves / nested sashes) of a SashForm, excluding its 
Sash dividers. */
+  private List<Control> nodeChildren(SashForm sashForm) {
+    List<Control> result = new ArrayList<>();
+    for (Control child : sashForm.getChildren()) {
+      if (child instanceof CTabFolder || child instanceof SashForm) {
+        result.add(child);
+      }
+    }
+    return result;
+  }
+
+  private boolean isEditorTabFolder(Control control) {
+    return control instanceof CTabFolder folder && 
getTabFolders().contains(folder);
+  }
+
+  /** True when every editor pane is empty (no open files anywhere in the 
tree). */
+  private boolean isEditorEmpty() {
+    for (CTabFolder folder : getTabFolders()) {
+      if (folder.getItemCount() > 0) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Split {@code folder} along {@code orientation}, adding a new empty 
sibling leaf and returning
+   * it. The existing folder is wrapped in a new {@link SashForm} that takes 
its place in the tree.
+   *
+   * @param folder the leaf to split
+   * @param orientation {@link SWT#HORIZONTAL} (side-by-side) or {@link 
SWT#VERTICAL} (stacked)
+   * @param after when true the new leaf is placed after (right/below) the 
existing folder
+   * @return the newly created empty leaf folder
+   */
+  private CTabFolder splitFolder(CTabFolder folder, int orientation, boolean 
after) {
+    Composite parent = folder.getParent();
+
+    // Remember the folder's position among its parent's node children (for a 
SashForm parent).
+    int indexInParent = -1;
+    int[] parentWeights = null;
+    if (parent instanceof SashForm parentSash) {
+      indexInParent = nodeChildren(parentSash).indexOf(folder);
+      parentWeights = parentSash.getWeights();
+    }
+
+    SashForm newSash = new SashForm(parent, orientation);
+    newSash.setLayoutData(new FormDataBuilder().fullSize().result());
+    PropsUi.setLook(newSash);
+
+    // Move the existing folder under the new sash, then add the new sibling.
+    folder.setParent(newSash);
+    CTabFolder newFolder = createSingleTabFolder(newSash, true);
+    if (!after) {
+      newFolder.moveAbove(folder);
+    }
+    newSash.setWeights(50, 50);
+
+    if (parent instanceof SashForm parentSash) {
+      // Keep the new sash where the old folder was, and restore the parent's 
weights.
+      if (indexInParent >= 0) {
+        Control[] order = nodeChildren(parentSash).toArray(new Control[0]);
+        // nodeChildren returns children in z-order; ensure newSash sits at 
indexInParent.
+        if (indexInParent < order.length - 1) {
+          newSash.moveAbove(order[indexInParent]);
+        }
+      }
+      if (parentWeights != null && parentWeights.length == 
nodeChildren(parentSash).size()) {
+        parentSash.setWeights(parentWeights);
+      }
+    } else {
+      editorRoot = newSash;
+    }
+
+    parent.layout(true, true);
+    return newFolder;
+  }
+
+  /**
+   * Remove {@code folder} if it is empty and not the last remaining pane, 
collapsing its parent
+   * SashForm when only one node child would be left.
+   */
+  private void collapseFolder(CTabFolder folder) {
+    if (folder == null || folder.isDisposed() || folder.getItemCount() > 0) {
+      return;
+    }
+    Composite parent = folder.getParent();
+    if (!(parent instanceof SashForm sashForm)) {
+      // The single root pane: keep it even when empty.
+      return;
+    }
+    Composite grandParent = sashForm.getParent();
+
+    folder.dispose();
+
+    List<Control> remaining = nodeChildren(sashForm);
+    if (remaining.size() == 1) {
+      Control survivor = remaining.get(0);
+
+      int indexInGrand = -1;
+      int[] grandWeights = null;
+      if (grandParent instanceof SashForm grandSash) {
+        indexInGrand = nodeChildren(grandSash).indexOf(sashForm);
+        grandWeights = grandSash.getWeights();
+      }
+
+      survivor.setParent(grandParent);
+      sashForm.dispose();
+
+      if (grandParent instanceof SashForm grandSash) {
+        if (indexInGrand >= 0) {
+          Control[] order = nodeChildren(grandSash).toArray(new Control[0]);
+          if (indexInGrand < order.length - 1) {
+            survivor.moveAbove(order[indexInGrand]);
+          }
+        }
+        if (grandWeights != null && grandWeights.length == 
nodeChildren(grandSash).size()) {
+          grandSash.setWeights(grandWeights);
+        }
+      } else {
+        // Moving back under the FormLayout wrapper: the survivor still 
carries the SashForm's
+        // SashFormData, which FormLayout rejects with a ClassCastException on 
its next layout pass.
+        // Give it FormData that fills the wrapper (mirrors createTabFolder's 
root folder).
+        survivor.setLayoutData(new FormDataBuilder().fullSize().result());
+        editorRoot = survivor;
+      }
+      grandParent.layout(true, true);
+    } else {
+      sashForm.layout(true, true);
+    }
+
+    if (activeTabFolder == folder || activeTabFolder == null || 
activeTabFolder.isDisposed()) {
+      List<CTabFolder> leaves = getTabFolders();
+      activeTabFolder = leaves.isEmpty() ? null : leaves.get(0);
+    }
+  }
+
+  /** Reclaim every empty leaf: collapse docked panes, close empty detached 
windows. */
+  private void collapseEmptyFolders() {
+    for (CTabFolder folder : getTabFolders()) {
+      if (folder.getItemCount() == 0) {
+        reclaimFolder(folder);
+      }
+    }
+  }
+
+  /**
+   * Reclaim a folder that a tab just left: a docked pane collapses back into 
its sash, an emptied
+   * detached window closes.
+   */
+  private void reclaimFolder(CTabFolder folder) {
+    if (folder == null || folder.isDisposed()) {
+      return;
+    }
+    if (detachedFolders.contains(folder)) {
+      if (folder.getItemCount() == 0) {
+        closeDetachedWindow(folder);
+      }
+    } else {
+      collapseFolder(folder);
+    }
   }
 
   @GuiMenuElement(
@@ -4017,36 +4265,6 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
   }
 
-  /** Split the editor to show two files side by side. If already split, this 
is a no-op. */
-  public void splitEditor() {
-    if (editorSplit) {
-      return;
-    }
-    editorSplit = true;
-    editorSash.setMaximizedControl(null);
-    editorSash.setWeights(50, 50);
-    editorSash.layout(true);
-  }
-
-  /**
-   * Unsplit the editor back to a single pane, moving all tabs from the second 
pane to the first.
-   */
-  public void unsplitEditor() {
-    if (!editorSplit) {
-      return;
-    }
-
-    CTabItem[] itemsToMove = tabFolder2.getItems();
-    for (CTabItem srcItem : itemsToMove) {
-      moveTabToFolder(srcItem, tabFolder);
-    }
-
-    editorSplit = false;
-    activeTabFolder = tabFolder;
-    editorSash.setMaximizedControl(tabFolder);
-    editorSash.layout(true);
-  }
-
   private void moveTabToFolder(CTabItem srcItem, CTabFolder dstFolder) {
     String text = srcItem.getText();
     Image image = srcItem.getImage();
@@ -4084,25 +4302,19 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
   }
 
-  /** Move a tab to the other split pane. If not currently split, creates the 
split first. */
+  /** Split the tab's folder to the right and move the tab into the new pane. 
*/
   private void splitOrMoveTab(CTabItem tab) {
     CTabFolder sourceFolder = tab.getParent();
-    CTabFolder targetFolder;
 
-    if (!editorSplit) {
-      splitEditor();
-      targetFolder = tabFolder2;
-    } else {
-      targetFolder = (sourceFolder == tabFolder) ? tabFolder2 : tabFolder;
-    }
+    CTabFolder targetFolder = splitFolder(sourceFolder, SWT.HORIZONTAL, true);
 
     moveTabToFolder(tab, targetFolder);
     targetFolder.setSelection(targetFolder.getItemCount() - 1);
     activeTabFolder = targetFolder;
 
-    if (editorSplit && sourceFolder.getItemCount() == 0) {
-      unsplitEditor();
-    }
+    // If the source pane emptied out (shouldn't normally happen, the menu 
requires >1 tab),
+    // collapse.
+    collapseFolder(sourceFolder);
 
     // Match what addPipeline/addWorkflow do after setSelection: give focus to 
the moved tab's
     // control and refresh the GUI so toolbar/menu state is up to date.
@@ -4116,18 +4328,153 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @Override
   public void onTabMovedBetweenFolders(CTabFolder sourceFolder, CTabFolder 
targetFolder) {
     activeTabFolder = targetFolder;
-    if (editorSplit && sourceFolder.getItemCount() == 0) {
-      unsplitEditor();
-    }
+    reclaimFolder(sourceFolder);
   }
 
   @Override
   public void setDropTargetFolder(CTabFolder folder) {
-    if (folder == tabFolder || folder == tabFolder2) {
+    if (isEditorTabFolder(folder)) {
       activeTabFolder = folder;
     }
   }
 
+  @Override
+  public CTabFolder resolveDropFolderForZone(CTabFolder targetFolder, int 
zone) {
+    // Only the docked layout tree supports splitting; detached windows stay 
single-pane in v1.
+    if (targetFolder == null || targetFolder.isDisposed() || 
!isDockedTabFolder(targetFolder)) {
+      return targetFolder;
+    }
+    return switch (zone) {
+      case DROP_ZONE_WEST -> splitFolder(targetFolder, SWT.HORIZONTAL, false);
+      case DROP_ZONE_EAST -> splitFolder(targetFolder, SWT.HORIZONTAL, true);
+      case DROP_ZONE_NORTH -> splitFolder(targetFolder, SWT.VERTICAL, false);
+      case DROP_ZONE_SOUTH -> splitFolder(targetFolder, SWT.VERTICAL, true);
+      default -> targetFolder;
+    };
+  }
+
+  /**
+   * Detach a tab into its own floating window. The tab's content is 
reparented (state preserved)
+   * into a single-pane folder hosted by a new {@link Shell}. Closing the 
window re-docks its tabs;
+   * emptying it closes the window. Tabs can also be dragged back and forth 
between windows.
+   */
+  private void detachTabToWindow(CTabItem tab) {
+    if (tab == null || tab.isDisposed()) {
+      return;
+    }
+    CTabFolder sourceFolder = tab.getParent();
+
+    Shell shell = new Shell(hopGui.getShell(), SWT.SHELL_TRIM);
+    shell.setText(Const.NVL(tab.getText(), ""));
+    shell.setImage(tab.getImage());
+    shell.setLayout(new FormLayout());
+
+    CTabFolder detached = createDetachedTabFolder(shell);
+    detachedFolders.add(detached);
+    shell.addDisposeListener(e -> detachedFolders.remove(detached));
+
+    moveTabToFolder(tab, detached);
+    detached.setSelection(detached.getItemCount() - 1);
+    activeTabFolder = detached;
+
+    // Collapse the docked source pane if the detach emptied it.
+    reclaimFolder(sourceFolder);
+
+    // The window's close box re-docks its tabs rather than discarding them.
+    shell.addListener(
+        SWT.Close,
+        e -> {
+          e.doit = false;
+          redockWindow(detached);
+        });
+
+    shell.setSize(900, 650);
+    shell.setLocation(
+        hopGui.getShell().getLocation().x + 60, 
hopGui.getShell().getLocation().y + 60);
+    shell.open();
+
+    CTabItem sel = detached.getSelection();
+    if (sel != null && sel.getControl() != null && 
!sel.getControl().isDisposed()) {
+      sel.getControl().setFocus();
+    }
+    updateGui();
+  }
+
+  /** Move every tab from a detached window back into the docked editor, then 
close the window. */
+  private void redockWindow(CTabFolder detached) {
+    if (detached == null || detached.isDisposed()) {
+      return;
+    }
+    List<CTabFolder> docked = getDockedTabFolders();
+    CTabFolder target = docked.isEmpty() ? null : docked.get(0);
+    if (target != null) {
+      for (CTabItem item : detached.getItems()) {
+        moveTabToFolder(item, target);
+      }
+      activeTabFolder = target;
+    }
+    detachedFolders.remove(detached);
+    Shell shell = detached.getShell();
+    if (!shell.isDisposed()) {
+      shell.dispose();
+    }
+    updateGui();
+  }
+
+  /** Close an emptied detached window (its last tab was closed or dragged 
out). */
+  private void closeDetachedWindow(CTabFolder folder) {
+    detachedFolders.remove(folder);
+    if (activeTabFolder == folder) {
+      List<CTabFolder> docked = getDockedTabFolders();
+      activeTabFolder = docked.isEmpty() ? null : docked.get(0);
+    }
+    Shell shell = folder.getShell();
+    if (shell != null && !shell.isDisposed()) {
+      // We may be inside the tab-close / drop event that emptied the folder; 
disposing the owning
+      // Shell synchronously mid-event is unsafe, so defer it to the next 
event-loop tick.
+      shell
+          .getDisplay()
+          .asyncExec(
+              () -> {
+                if (!shell.isDisposed()) {
+                  shell.dispose();
+                }
+              });
+    }
+  }
+
+  /**
+   * Build a single-pane tab folder for a detached window: close + focus + 
drag, but no splitting.
+   */
+  private CTabFolder createDetachedTabFolder(Composite parent) {
+    CTabFolder folder = new CTabFolder(parent, SWT.MULTI | SWT.BORDER);
+    folder.setLayoutData(new FormDataBuilder().fullSize().result());
+    PropsUi.setLook(folder, Props.WIDGET_STYLE_TAB);
+
+    folder.addListener(
+        SWT.Selection,
+        e -> {
+          activeTabFolder = folder;
+          updateGui();
+          CTabItem selection = folder.getSelection();
+          if (selection != null && selection.getData() instanceof 
IHopFileTypeHandler handler) {
+            selectInTree(handler.getFilename());
+          }
+        });
+    folder.addListener(SWT.FocusIn, e -> activeTabFolder = folder);
+    folder.addCTabFolder2Listener(
+        new CTabFolder2Adapter() {
+          @Override
+          public void close(CTabFolderEvent event) {
+            closeTab(event, (CTabItem) event.item);
+          }
+        });
+
+    new TabCloseHandler(this, folder);
+    new TabItemReorder(this, folder);
+    return folder;
+  }
+
   /**
    * Check if the file explorer panel is currently visible.
    *
@@ -4151,18 +4498,12 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           new AuditState(
               STATE_PANEL_VISIBLE_KEY,
               Map.of(STATE_PANEL_VISIBLE_PROP, 
Boolean.valueOf(fileExplorerPanelVisible))));
-      stateMap.add(
-          new AuditState(
-              STATE_EDITOR_SPLIT_KEY,
-              Map.of(STATE_EDITOR_SPLIT_PROP, Boolean.valueOf(editorSplit))));
-      if (editorSash != null && !editorSash.isDisposed()) {
-        int[] weights = editorSash.getWeights();
-        if (weights != null && weights.length >= 2) {
-          stateMap.add(
-              new AuditState(
-                  STATE_EDITOR_SASH_WEIGHTS_KEY,
-                  Map.of(STATE_EDITOR_SASH_WEIGHTS_PROP, weights[0] + "," + 
weights[1])));
-        }
+      // Persist the recursive editor layout tree (splits + sash weights) so 
it survives a restart.
+      // Detached floating windows are not serialized: their tabs re-dock into 
the default pane.
+      Map<String, Object> layout = serializeEditorLayout();
+      if (layout != null) {
+        stateMap.add(
+            new AuditState(STATE_EDITOR_LAYOUT_KEY, 
Map.of(STATE_EDITOR_LAYOUT_ROOT, layout)));
       }
       AuditManager.getActive()
           .saveAuditStateMap(HopNamespace.getNamespace(), EXPLORER_AUDIT_TYPE, 
stateMap);
@@ -4198,51 +4539,178 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   }
 
   /**
-   * Load and apply saved editor split state (split on/off and sash weights). 
Called on startup
-   * before opening files so that tabs open in the correct pane, and from 
applyRestoredState when
-   * project changes.
+   * Rebuild the docked editor layout tree (splits + sash weights) from saved 
audit state and
+   * populate {@link #folderByLeafId} so reopened files can be routed to their 
saved pane. Called
+   * before files are reopened (see {@code 
HopGuiAuditDelegate.openLastFiles}). Only rebuilds when
+   * the editor is empty, so it never restructures over live tabs (e.g. a 
mid-session project
+   * switch), and falls back to the single default pane when there is no saved 
layout.
    */
   public void applyRestoredEditorSplitState() {
-    if (editorSash == null || editorSash.isDisposed()) {
+    if (tabFolderWrapper == null || tabFolderWrapper.isDisposed()) {
       return;
     }
+    if (!isEditorEmpty()) {
+      return;
+    }
+
+    folderByLeafId.clear();
+    Map<String, Object> layout = loadEditorLayout();
+
+    // No saved layout, or a single-pane layout: keep/reuse the default root 
folder.
+    if (layout == null || LAYOUT_TYPE_LEAF.equals(layout.get(LAYOUT_TYPE))) {
+      if (!(editorRoot instanceof CTabFolder) || editorRoot.isDisposed()) {
+        resetEditorToSinglePane();
+      }
+      folderByLeafId.put("L0", (CTabFolder) editorRoot);
+      return;
+    }
+
+    // Multi-pane layout: dispose the current (empty) tree and rebuild from 
the saved blob.
+    for (Control child : tabFolderWrapper.getChildren()) {
+      child.dispose();
+    }
+    int[] leafCounter = {0};
+    Control root = buildLayoutNode(layout, tabFolderWrapper, leafCounter);
+    root.setLayoutData(new FormDataBuilder().fullSize().result());
+    editorRoot = root;
+    List<CTabFolder> leaves = getDockedTabFolders();
+    activeTabFolder = leaves.isEmpty() ? null : leaves.get(0);
+    tabFolderWrapper.layout(true, true);
+  }
+
+  /**
+   * After files have been reopened into their restored panes, drop any leaf 
that ended up empty
+   * (its file failed to open) so no ghost panes remain.
+   */
+  public void finishEditorLayoutRestore() {
+    if (tabFolderWrapper == null || tabFolderWrapper.isDisposed()) {
+      return;
+    }
+    collapseEmptyFolders();
+  }
+
+  /** Dispose the current editor tree and replace it with a single empty 
primary tab folder. */
+  private void resetEditorToSinglePane() {
+    for (Control child : tabFolderWrapper.getChildren()) {
+      child.dispose();
+    }
+    CTabFolder root = createSingleTabFolder(tabFolderWrapper, true);
+    editorRoot = root;
+    activeTabFolder = root;
+    tabFolderWrapper.layout(true, true);
+  }
+
+  /** Load the saved editor layout tree, or {@code null} when none is stored. 
*/
+  private Map<String, Object> loadEditorLayout() {
     try {
       AuditStateMap stateMap =
           AuditManager.getActive()
               .loadAuditStateMap(HopNamespace.getNamespace(), 
EXPLORER_AUDIT_TYPE);
-      AuditState splitState = stateMap.get(STATE_EDITOR_SPLIT_KEY);
-      if (splitState != null) {
-        Object split = splitState.getStateMap().get(STATE_EDITOR_SPLIT_PROP);
-        boolean wasSplit = split instanceof Boolean && (Boolean) split;
-        if (wasSplit) {
-          editorSplit = true;
-          editorSash.setMaximizedControl(null);
-          AuditState weightsState = 
stateMap.get(STATE_EDITOR_SASH_WEIGHTS_KEY);
-          if (weightsState != null) {
-            Object weightsObj = 
weightsState.getStateMap().get(STATE_EDITOR_SASH_WEIGHTS_PROP);
-            if (weightsObj != null) {
-              String[] parts = weightsObj.toString().split(",");
-              if (parts.length >= 2) {
-                try {
-                  int w0 = Integer.parseInt(parts[0].trim());
-                  int w1 = Integer.parseInt(parts[1].trim());
-                  editorSash.setWeights(w0, w1);
-                } catch (NumberFormatException ignored) {
-                  editorSash.setWeights(50, 50);
-                }
-              }
-            } else {
-              editorSash.setWeights(50, 50);
-            }
+      AuditState state = stateMap.get(STATE_EDITOR_LAYOUT_KEY);
+      if (state != null
+          && state.getStateMap().get(STATE_EDITOR_LAYOUT_ROOT) instanceof 
Map<?, ?> m) {
+        @SuppressWarnings("unchecked")
+        Map<String, Object> root = (Map<String, Object>) m;
+        return root;
+      }
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error loading explorer editor layout", e);
+    }
+    return null;
+  }
+
+  /**
+   * Serialize the docked editor layout tree into nested maps/lists 
(Jackson-friendly for audit
+   * storage). Leaves are numbered "L0", "L1", ... in the same pre-order used 
by {@link
+   * #getLeafIdForTab(CTabItem)} and {@link #getDockedTabFolders()}. Returns 
{@code null} when there
+   * is nothing worth persisting (no editor tree yet).
+   */
+  private Map<String, Object> serializeEditorLayout() {
+    if (editorRoot == null || editorRoot.isDisposed()) {
+      return null;
+    }
+    return serializeLayoutNode(editorRoot, new int[] {0});
+  }
+
+  private Map<String, Object> serializeLayoutNode(Control node, int[] 
leafCounter) {
+    Map<String, Object> map = new LinkedHashMap<>();
+    if (node instanceof SashForm sashForm) {
+      map.put(LAYOUT_TYPE, LAYOUT_TYPE_SPLIT);
+      map.put(
+          LAYOUT_ORIENTATION,
+          sashForm.getOrientation() == SWT.VERTICAL
+              ? LAYOUT_ORIENTATION_VERTICAL
+              : LAYOUT_ORIENTATION_HORIZONTAL);
+      List<Map<String, Object>> children = new ArrayList<>();
+      for (Control child : nodeChildren(sashForm)) {
+        children.add(serializeLayoutNode(child, leafCounter));
+      }
+      map.put(LAYOUT_CHILDREN, children);
+      List<Integer> weights = new ArrayList<>();
+      for (int weight : sashForm.getWeights()) {
+        weights.add(weight);
+      }
+      map.put(LAYOUT_WEIGHTS, weights);
+    } else {
+      map.put(LAYOUT_TYPE, LAYOUT_TYPE_LEAF);
+      map.put(LAYOUT_LEAF_ID, "L" + leafCounter[0]++);
+    }
+    return map;
+  }
+
+  /**
+   * Rebuild a layout node (and its subtree) from the serialized form, 
registering each leaf in
+   * {@link #folderByLeafId}. Leaves are numbered in the same pre-order as 
serialization.
+   */
+  private Control buildLayoutNode(Map<String, Object> node, Composite parent, 
int[] leafCounter) {
+    if (LAYOUT_TYPE_SPLIT.equals(node.get(LAYOUT_TYPE))) {
+      int orientation =
+          LAYOUT_ORIENTATION_VERTICAL.equals(node.get(LAYOUT_ORIENTATION))
+              ? SWT.VERTICAL
+              : SWT.HORIZONTAL;
+      SashForm sashForm = new SashForm(parent, orientation);
+      sashForm.setLayoutData(new FormDataBuilder().fullSize().result());
+      PropsUi.setLook(sashForm);
+
+      Object childrenObj = node.get(LAYOUT_CHILDREN);
+      int childCount = 0;
+      if (childrenObj instanceof List<?> children) {
+        for (Object childObj : children) {
+          if (childObj instanceof Map<?, ?> childMap) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> child = (Map<String, Object>) childMap;
+            buildLayoutNode(child, sashForm, leafCounter);
+            childCount++;
+          }
+        }
+      }
+
+      if (node.get(LAYOUT_WEIGHTS) instanceof List<?> weightList
+          && weightList.size() == childCount
+          && childCount > 0) {
+        int[] weights = new int[childCount];
+        boolean valid = true;
+        for (int i = 0; i < childCount; i++) {
+          if (weightList.get(i) instanceof Number number) {
+            weights[i] = number.intValue();
           } else {
-            editorSash.setWeights(50, 50);
+            valid = false;
+            break;
           }
-          editorSash.layout(true);
+        }
+        if (valid) {
+          sashForm.setWeights(weights);
         }
       }
-    } catch (Exception e) {
-      hopGui.getLog().logError("Error restoring explorer editor split state", 
e);
+      return sashForm;
     }
+
+    // Leaf: the first leaf (L0) carries the primary maximize toolbar, 
mirroring createTabFolder.
+    boolean primary = leafCounter[0] == 0;
+    CTabFolder folder = createSingleTabFolder(parent, primary);
+    folderByLeafId.put("L" + leafCounter[0], folder);
+    leafCounter[0]++;
+    return folder;
   }
 
   /**
@@ -4265,6 +4733,9 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       }
     }
     applyRestoredEditorSplitState();
+    // Collapse any panes left empty (e.g. when reopening last files is 
disabled), so a saved
+    // multi-pane layout doesn't leave ghost empty panes behind.
+    finishEditorLayoutRestore();
   }
 
   public static class DetermineRootFolderExtension {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index 56a4eb1d9b..3589700252 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -1082,9 +1082,11 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   }
 
   /**
-   * Adds one "New &lt;type&gt;" item per metadata type to {@code menu}, 
optionally restricted to a
-   * single category. Types are grouped by category (in the configured 
category order, with a
-   * separator between groups) and keep the model's name order within a group.
+   * Adds "New &lt;type&gt;" items to {@code menu}, optionally restricted to a 
single category. When
+   * a single category is requested (the category context menu) the type items 
are added directly,
+   * since the category is already chosen. Otherwise each category becomes a 
cascade submenu so the
+   * (long) list of metadata types stays compact. Categories follow the 
configured category order
+   * and types keep the model's name order within a category.
    */
   private void addNewTypeMenuItems(Menu menu, String onlyCategoryId) {
     List<String> categoryIds = new ArrayList<>();
@@ -1098,29 +1100,49 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
         Comparator.comparingInt(MetadataCategories::orderOf)
             .thenComparing(MetadataCategories::labelFor));
 
-    boolean needSeparator = false;
     for (String categoryId : categoryIds) {
-      if (needSeparator) {
-        new MenuItem(menu, SWT.SEPARATOR);
-      }
-      for (MetadataTypeModel typeModel : typeModels) {
-        if (!typeModel.categoryId.equals(categoryId)) {
-          continue;
-        }
-        MenuItem typeMenuItem = new MenuItem(menu, SWT.POP_UP);
-        typeMenuItem.setText(
-            BaseMessages.getString(PKG, "MetadataPerspective.Menu.NewOfType", 
typeModel.typeName));
-        typeMenuItem.setImage(
+      if (onlyCategoryId != null) {
+        // Scoped to one category: add the type items straight into the menu.
+        addTypeItems(menu, categoryId);
+      } else {
+        // Every category becomes a cascade submenu (a "sub folder").
+        MenuItem categoryMenuItem = new MenuItem(menu, SWT.CASCADE);
+        categoryMenuItem.setText(MetadataCategories.labelFor(categoryId));
+        categoryMenuItem.setImage(
             GuiResource.getInstance()
                 .getImage(
-                    typeModel.image,
-                    typeModel.metadataClass.getClassLoader(),
+                    MetadataCategories.imageFor(categoryId),
+                    getClass().getClassLoader(),
                     ConstUi.SMALL_ICON_SIZE,
                     ConstUi.SMALL_ICON_SIZE));
-        String key = typeModel.key;
-        typeMenuItem.addListener(SWT.Selection, e -> createMetadataOfType(key, 
""));
+        Menu subMenu = new Menu(menu);
+        categoryMenuItem.setMenu(subMenu);
+        addTypeItems(subMenu, categoryId);
+      }
+    }
+  }
+
+  /**
+   * Appends one "New &lt;type&gt;" push item per metadata type of {@code 
categoryId} to {@code
+   * menu}.
+   */
+  private void addTypeItems(Menu menu, String categoryId) {
+    for (MetadataTypeModel typeModel : typeModels) {
+      if (!typeModel.categoryId.equals(categoryId)) {
+        continue;
       }
-      needSeparator = true;
+      MenuItem typeMenuItem = new MenuItem(menu, SWT.POP_UP);
+      typeMenuItem.setText(
+          BaseMessages.getString(PKG, "MetadataPerspective.Menu.NewOfType", 
typeModel.typeName));
+      typeMenuItem.setImage(
+          GuiResource.getInstance()
+              .getImage(
+                  typeModel.image,
+                  typeModel.metadataClass.getClassLoader(),
+                  ConstUi.SMALL_ICON_SIZE,
+                  ConstUi.SMALL_ICON_SIZE));
+      String key = typeModel.key;
+      typeMenuItem.addListener(SWT.Selection, e -> createMetadataOfType(key, 
""));
     }
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
index 1c50574deb..4cdba0f323 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
@@ -109,6 +109,9 @@ public class HopGuiBottomDock extends Composite implements 
TabClosable {
   /** Reserved state key for terminal font size percent (e.g. 100 = 100%). */
   private static final String STATE_TERMINAL_FONT_SIZE_PERCENT_KEY = 
"terminalFontSizePercent";
 
+  /** Reserved state key for the dock's height as a percentage of the vertical 
sash. */
+  private static final String STATE_TERMINAL_HEIGHT_PERCENT_KEY = 
"terminalHeightPercent";
+
   // State map keys
   private static final String STATE_TAB_NAME = "tabName";
   private static final String STATE_SHELL_PATH = "shellPath";
@@ -817,6 +820,14 @@ public class HopGuiBottomDock extends Composite implements 
TabClosable {
               STATE_TERMINAL_FONT_SIZE_PERCENT_KEY,
               java.util.Map.of("value", 
Integer.valueOf(terminalFontSizePercent))));
 
+      // Persist the dock height. When visible, read the live sash ratio so a 
user-dragged divider
+      // is captured (there is no drag listener updating 
terminalHeightPercent); otherwise keep the
+      // last known value.
+      stateMap.add(
+          new AuditState(
+              STATE_TERMINAL_HEIGHT_PERCENT_KEY,
+              java.util.Map.of("value", 
Integer.valueOf(getCurrentTerminalHeightPercent()))));
+
       AuditList auditList = new AuditList(terminalIds);
       AuditManager.getActive()
           .storeList(HopNamespace.getNamespace(), TERMINAL_AUDIT_TYPE, 
auditList);
@@ -947,6 +958,15 @@ public class HopGuiBottomDock extends Composite implements 
TabClosable {
         terminalFontSizePercent = Math.clamp(saved, 50, 200);
       }
 
+      // Restore dock height percent (applied by showDock/showTerminal when 
the panel is shown)
+      AuditState heightState = stateMap.get(STATE_TERMINAL_HEIGHT_PERCENT_KEY);
+      if (heightState != null
+          && heightState.getStateMap() != null
+          && heightState.getStateMap().get("value") != null) {
+        int saved = 
Const.toInt(heightState.getStateMap().get("value").toString(), 35);
+        terminalHeightPercent = Math.clamp(saved, 5, 95);
+      }
+
       if (auditList.getNames().isEmpty()) {
         return;
       }
@@ -995,6 +1015,30 @@ public class HopGuiBottomDock extends Composite 
implements TabClosable {
     }
   }
 
+  /**
+   * The dock's current height as a percentage of the vertical sash. When the 
dock is visible this
+   * reads the live sash weights (capturing a user-dragged divider); otherwise 
it returns the last
+   * known {@link #terminalHeightPercent}.
+   */
+  private int getCurrentTerminalHeightPercent() {
+    if (terminalVisible
+        && verticalSash != null
+        && !verticalSash.isDisposed()
+        && verticalSash.getMaximizedControl() == null) {
+      int[] weights = verticalSash.getWeights();
+      if (weights.length == 2) {
+        long total = (long) weights[0] + weights[1];
+        if (total > 0) {
+          int percent = (int) Math.round(weights[1] * 100.0 / total);
+          if (percent > 0 && percent < 100) {
+            terminalHeightPercent = percent;
+          }
+        }
+      }
+    }
+    return terminalHeightPercent;
+  }
+
   /** Set terminal height percentage */
   public void setTerminalHeightPercent(int percent) {
     if (percent > 0 && percent < 100) {
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
index e1e59c8624..a9f77fcb39 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
@@ -142,6 +142,9 @@ 
WorkflowGraph.ExecutionResultsPanel.CloseButton.Tooltip=Close the execution resu
 WorkflowGraph.ExecutionResultsPanel.MaxButton.Tooltip=Maximize the execution 
results panel
 WorkflowGraph.ExecutionResultsPanel.MinButton.Tooltip=Minimize the execution 
results panel
 WorkflowGraph.ExecutionResultsPanel.RotateButton.Tooltip=Rotate the execution 
results panel
+WorkflowGraph.ExecutionResultsPanel.DetachButton.Tooltip=Detach the execution 
results panel into a floating window
+WorkflowGraph.ExecutionResultsPanel.DockButton.Tooltip=Dock the execution 
results panel back into the workflow
+WorkflowGraph.ExecutionResultsPanel.Window.Title=Execution results - {0}
 WorkflowGraph.Hop.CreateHopToStartAction.Tooltip=The start action can only be 
used at the start of a Workflow
 WorkflowGraph.Hop.Tooltip.EvaluatingFalse=After the execution of {0}, the 
result of the execution will be evaluated.{1}If the result is false (with 
errors) this path will be taken.
 WorkflowGraph.Hop.Tooltip.EvaluatingTrue=After the execution of {0}, the 
result of the execution will be evaluated.{1}If the result is true (without 
errors) this path will be taken.
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 0aeb25e71a..14db42b96d 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -192,6 +192,9 @@ 
PipelineGraph.ExecutionResultsPanel.CloseButton.Tooltip=Close the execution resu
 PipelineGraph.ExecutionResultsPanel.MaxButton.Tooltip=Maximize the execution 
results panel
 PipelineGraph.ExecutionResultsPanel.MinButton.Tooltip=Minimize the execution 
results panel
 PipelineGraph.ExecutionResultsPanel.RotateButton.Tooltip=Rotate the execution 
results panel
+PipelineGraph.ExecutionResultsPanel.DetachButton.Tooltip=Detach the execution 
results panel into a floating window
+PipelineGraph.ExecutionResultsPanel.DockButton.Tooltip=Dock the execution 
results panel back into the pipeline
+PipelineGraph.ExecutionResultsPanel.Window.Title=Execution results - {0}
 PipelineGraph.Hop.Tooltip.HopTypeCopy=The origin transform [{0}] is copying 
all output rows to all target transforms.{1}Please note that this is different 
from the default behavior where rows are distributed over the target transforms 
in a round robin fashion.{1}
 PipelineGraph.Hop.Tooltip.HopTypeError=Each row that was considered to be a 
cause for an error by transform [{0}] is sent to transform [{1}].{2}This is 
done as part of the error handling configuration of the source transform.{2}
 PipelineGraph.Hop.Tooltip.HopTypeInfo=Rows are being sent to transform [{0}] 
so that they can be used as additional information.{2}That target transform 
specifically reads from transform [{1}] and it is treated as a special 
case.{2}That means that for this source transform the normal row reading rules 
don''t apply.{2}Normally a transform reads information from all source 
transforms in a round robin fashion.{2}
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
index 8290f42516..ab2c289386 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
@@ -61,6 +61,7 @@ 
ExplorerPerspective.ToolbarElement.SelectOpenedFile.Tooltip=Select opened file
 ExplorerPerspective.ToolbarElement.OpenAsText.Label=Open as text
 ExplorerPerspective.TabMenu.MoveToRight=Move to Right
 ExplorerPerspective.TabMenu.MoveToLeft=Move to Left
+ExplorerPerspective.TabMenu.MoveToNewWindow=Move to New Window
 ExplorerPerspective.UpdateFileReferences.Title=Update file references
 ExplorerPerspective.UpdateFileReferences.Message=Found {0} reference(s) in {1} 
pipeline/workflow file(s). Update references to the new path(s)?
 ExplorerPerspective.UpdateFileReferences.MessageWithMetadata=Found {0} 
reference(s) in {1} pipeline/workflow file(s) and {2} metadata object(s). 
Update references to the new path(s)?
diff --git a/ui/src/main/resources/ui/images/detach-panel.svg 
b/ui/src/main/resources/ui/images/detach-panel.svg
new file mode 100644
index 0000000000..6a2d598a75
--- /dev/null
+++ b/ui/src/main/resources/ui/images/detach-panel.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24">
+    <path fill="#0F3B5A" 
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 
3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"/>
+</svg>
diff --git a/ui/src/main/resources/ui/images/dock-panel.svg 
b/ui/src/main/resources/ui/images/dock-panel.svg
new file mode 100644
index 0000000000..a191b9bc1a
--- /dev/null
+++ b/ui/src/main/resources/ui/images/dock-panel.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24">
+    <path fill="#0F3B5A" d="M20,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 
0,0 22,18V6A2,2 0 0,0 20,4M20,18H4V13H20V18Z"/>
+</svg>

Reply via email to