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 3fe5ab0bc0 Split-Screen View, fixes #5715 (#6689)
3fe5ab0bc0 is described below

commit 3fe5ab0bc0e3b2d7d5996c1ae82adc0cad02acb5
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Mar 2 20:09:37 2026 +0100

    Split-Screen View, fixes #5715 (#6689)
---
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java |   3 +-
 .../ui/hopgui/delegates/HopGuiAuditDelegate.java   | 114 +++-
 .../ui/hopgui/delegates/HopGuiFileDelegate.java    |   9 +-
 .../ui/hopgui/file/pipeline/HopGuiLogBrowser.java  |  10 +-
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  12 +
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |  12 +
 .../hop/ui/hopgui/perspective/IHopPerspective.java |  20 +
 .../hop/ui/hopgui/perspective/TabClosable.java     |  13 +-
 .../hop/ui/hopgui/perspective/TabCloseHandler.java |   9 +-
 .../hop/ui/hopgui/perspective/TabItemReorder.java  | 102 +++-
 .../perspective/explorer/ExplorerPerspective.java  | 638 ++++++++++++++++-----
 .../explorer/messages/messages_en_US.properties    |   2 +
 12 files changed, 764 insertions(+), 180 deletions(-)

diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 44e5360658..d98ee62323 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -215,8 +215,7 @@ public class ProjectsGuiPlugin {
       hopGui.setVariables(variables);
 
       // Re-open last open files for the namespace
-      //
-      hopGui.auditDelegate.openLastFiles();
+      hopGui.getDisplay().asyncExec(() -> 
hopGui.auditDelegate.openLastFiles());
 
       // Restore terminal tabs for the new project (per-project terminals)
       //
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 8d4f11e1f8..4903c5abe1 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
@@ -20,6 +20,7 @@ package org.apache.hop.ui.hopgui.delegates;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Pattern;
 import org.apache.commons.lang.StringUtils;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.history.AuditList;
@@ -36,9 +37,12 @@ import org.apache.hop.ui.core.gui.HopNamespace;
 import org.apache.hop.ui.core.metadata.MetadataEditor;
 import org.apache.hop.ui.core.metadata.MetadataManager;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.HopFileTypeRegistry;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
 import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
+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;
@@ -48,8 +52,24 @@ public class HopGuiAuditDelegate {
   private static final Class<?> PKG = HopGuiAuditDelegate.class;
 
   public static final String STATE_PROPERTY_ACTIVE = "active";
+
+  /** Explorer perspective: pane index 0 = left, 1 = right. */
+  public static final String STATE_PROPERTY_PANE = "pane";
+
   public static final String METADATA_FILENAME_PREFIX = "METADATA:";
 
+  /**
+   * File type name in state so the same file can be restored in different 
modes (e.g. pipeline vs
+   * text).
+   */
+  public static final String STATE_PROPERTY_FILETYPE = "fileType";
+
+  /**
+   * Delimiter between file type name and filename in the audit key. Enables 
multiple tabs for the
+   * same file. Uses a character that does not appear in paths or type names.
+   */
+  private static final String TAB_KEY_DELIMITER = "\u0001";
+
   private HopGui hopGui;
 
   public HopGuiAuditDelegate(HopGui hopGui) {
@@ -98,6 +118,11 @@ public class HopGuiAuditDelegate {
           auditStateMap = new AuditStateMap();
         }
 
+        // Restore editor split state before opening files so tabs land in the 
correct pane
+        if (perspective instanceof ExplorerPerspective explorerPerspective) {
+          explorerPerspective.applyRestoredEditorSplitState();
+        }
+
         for (String filename : auditList.getNames()) {
           try {
             if (StringUtils.isNotEmpty(filename)) {
@@ -112,11 +137,55 @@ public class HopGuiAuditDelegate {
                   openMetadataObject(className, name);
                 }
               } else {
-                // Regular filename
-                IHopFileTypeHandler fileTypeHandler = 
hopGui.fileDelegate.fileOpen(filename, false);
+                // Regular file: key may be composite 
"fileTypeName\u0001filename" or legacy
+                // "filename"
+                String tabKey = filename;
+                String resolvedFilename = filename;
+                IHopFileType hopFileToUse = null;
+                if (filename.contains(TAB_KEY_DELIMITER)) {
+                  String[] parts = 
filename.split(Pattern.quote(TAB_KEY_DELIMITER), 2);
+                  if (parts.length >= 2) {
+                    String fileTypeName = parts[0];
+                    resolvedFilename = parts[1];
+                    hopFileToUse =
+                        
HopFileTypeRegistry.getInstance().getFileTypeByName(fileTypeName);
+                  }
+                }
+                if (hopFileToUse == null) {
+                  hopFileToUse =
+                      
HopFileTypeRegistry.getInstance().findHopFileType(resolvedFilename);
+                }
+
+                // Set target pane for Explorer split view 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());
+                  }
+                }
+
+                IHopFileTypeHandler fileTypeHandler = null;
+                if (hopFileToUse != null) {
+                  fileTypeHandler =
+                      hopGui.fileDelegate.fileOpenWithType(resolvedFilename, 
hopFileToUse, false);
+                } else {
+                  fileTypeHandler = 
hopGui.fileDelegate.fileOpen(resolvedFilename, false);
+                }
                 if (fileTypeHandler != null) {
                   // Restore zoom, scroll and so on
-                  AuditState auditState = auditStateMap.get(filename);
+                  AuditState auditState = auditStateMap.get(tabKey);
                   if (auditState != null) {
                     
fileTypeHandler.applyStateProperties(auditState.getStateMap());
 
@@ -125,13 +194,22 @@ public class HopGuiAuditDelegate {
                       activeFileTypeHandler = fileTypeHandler;
                     }
                   }
+                } else if (hopFileToUse == null) {
+                  failedFiles.add(resolvedFilename);
                 }
               }
             }
           } catch (Exception e) {
             // Collect failed files instead of showing error dialog immediately
-            hopGui.getLog().logError("Error opening file '" + filename + "'", 
e);
-            failedFiles.add(filename);
+            String displayName = filename;
+            if (filename.contains(TAB_KEY_DELIMITER)) {
+              String[] p = filename.split(Pattern.quote(TAB_KEY_DELIMITER), 2);
+              if (p.length >= 2) {
+                displayName = p[1];
+              }
+            }
+            hopGui.getLog().logError("Error opening file '" + displayName + 
"'", e);
+            failedFiles.add(displayName);
           }
         }
 
@@ -232,22 +310,32 @@ public class HopGuiAuditDelegate {
       IHopFileTypeHandler activeFileTypeHandler = 
perspective.getActiveFileTypeHandler();
       List<TabItemHandler> tabItems = perspective.getItems();
       if (tabItems != null) {
+        // Use pane order for Explorer (left then right) so restore order 
matches split layout
+        List<TabItemHandler> tabItemsToSave =
+            perspective instanceof ExplorerPerspective ep
+                ? ep.getTabItemHandlersInPaneOrder()
+                : tabItems;
+
         // This perspective has the ability to handle multiple files.
         // Let's save the files in the given order...
         //
         AuditStateMap auditStateMap = new AuditStateMap();
 
         List<String> files = new ArrayList<>();
-        for (TabItemHandler tabItem : tabItems) {
+        for (TabItemHandler tabItem : tabItemsToSave) {
           IHopFileTypeHandler typeHandler = tabItem.getTypeHandler();
           String filename = typeHandler.getFilename();
           String name = typeHandler.getName();
           if (StringUtils.isNotEmpty(filename)) {
-            // Regular filename
+            // Regular filename — use composite key (fileType + filename) so 
same file in
+            // different modes (e.g. pipeline vs text) gets separate tabs
             //
-            files.add(filename);
+            IHopFileType fileType = typeHandler.getFileType();
+            String tabKey =
+                (fileType != null ? fileType.getName() : "") + 
TAB_KEY_DELIMITER + filename;
+            files.add(tabKey);
 
-            // Also save the state : active, zoom, ...
+            // Also save the state : active, zoom, pane (Explorer split), 
fileType, ...
             //
             Map<String, Object> stateProperties = 
typeHandler.getStateProperties();
             boolean active =
@@ -255,8 +343,14 @@ public class HopGuiAuditDelegate {
                     && activeFileTypeHandler.getFilename() != null
                     && activeFileTypeHandler.getFilename().equals(filename);
             stateProperties.put(STATE_PROPERTY_ACTIVE, active);
+            if (fileType != null) {
+              stateProperties.put(STATE_PROPERTY_FILETYPE, fileType.getName());
+            }
+            if (perspective instanceof ExplorerPerspective ep) {
+              stateProperties.put(STATE_PROPERTY_PANE, 
ep.getPaneIndexForTab(tabItem.getTabItem()));
+            }
 
-            auditStateMap.add(new AuditState(filename, stateProperties));
+            auditStateMap.add(new AuditState(tabKey, stateProperties));
           } else if (typeHandler instanceof MetadataEditor<?> metadataEditor
               && StringUtils.isNotEmpty(name)) {
             // Don't save new unchanged metadata objects...
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
index 2a949cd6cd..a7c317fff8 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
@@ -108,7 +108,6 @@ public class HopGuiFileDelegate {
 
   public IHopFileTypeHandler fileOpen(String filename, boolean 
activatePerspective)
       throws Exception {
-
     HopFileTypeRegistry fileRegistry = HopFileTypeRegistry.getInstance();
     IHopFileType hopFile = fileRegistry.findHopFileType(filename);
     if (hopFile == null) {
@@ -119,7 +118,15 @@ public class HopGuiFileDelegate {
               + filename
               + "'");
     }
+    return fileOpenWithType(filename, hopFile, activatePerspective);
+  }
 
+  /**
+   * Open a file with a specific file type. Used when restoring tabs so the 
same file can be
+   * reopened in different modes (e.g. pipeline and text).
+   */
+  public IHopFileTypeHandler fileOpenWithType(
+      String filename, IHopFileType hopFile, boolean activatePerspective) 
throws Exception {
     IHopFileTypeHandler fileTypeHandler = hopFile.openFile(hopGui, filename, 
hopGui.getVariables());
     if (fileTypeHandler != null) {
       hopGui.handleFileCapabilities(
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java
index fa939b33db..7238a50912 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java
@@ -95,11 +95,7 @@ public class HopGuiLogBrowser {
                     () -> {
                       IHasLogChannel provider = 
logProvider.getLogChannelProvider();
 
-                      if (provider != null
-                          && !text.isDisposed()
-                          && !busy.get()
-                          && !paused.get()
-                          && text.isVisible()) {
+                      if (provider != null && !text.isDisposed() && 
!busy.get() && !paused.get()) {
                         busy.set(true);
 
                         ILogChannel logChannel = provider.getLogChannel();
@@ -218,7 +214,9 @@ public class HopGuiLogBrowser {
                               }
                             }
 
-                            text.setSelection(text.getCharCount());
+                            if (!text.isDisposed()) {
+                              text.setSelection(text.getCharCount());
+                            }
                             lastLogId.set(lastNr);
                           }
                         }
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 f40d80f903..1912ef774c 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
@@ -542,6 +542,16 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     }
 
     canvas.addPaintListener(this::paintControl);
+    addListener(
+        SWT.Show,
+        e ->
+            getDisplay()
+                .asyncExec(
+                    () -> {
+                      if (!isDisposed() && canvas != null && 
!canvas.isDisposed()) {
+                        canvas.redraw();
+                      }
+                    }));
 
     selectedTransforms = null;
     lastClick = null;
@@ -565,6 +575,8 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
     hopGui.replaceKeyboardShortcutListeners(this);
     canvas.pack();
 
+    addListener(SWT.Resize, e -> redraw());
+
     // Update menu, toolbar, force redraw canvas
     //
     updateGui();
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 bd205fa48e..1c4574e587 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
@@ -462,6 +462,16 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     }
 
     canvas.addPaintListener(this::paintControl);
+    addListener(
+        SWT.Show,
+        e ->
+            getDisplay()
+                .asyncExec(
+                    () -> {
+                      if (!isDisposed() && canvas != null && 
!canvas.isDisposed()) {
+                        canvas.redraw();
+                      }
+                    }));
 
     selectedActions = null;
     lastClick = null;
@@ -478,6 +488,8 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
 
     canvas.pack();
 
+    addListener(SWT.Resize, e -> redraw());
+
     updateGui();
   }
 
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 2889997ef5..7f562a0c87 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
@@ -24,6 +24,7 @@ import 
org.apache.hop.ui.hopgui.context.IActionContextHandlersProvider;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
+import org.eclipse.swt.custom.CTabFolder;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 
@@ -127,4 +128,23 @@ public interface IHopPerspective extends 
IActionContextHandlersProvider {
   default List<ISearchable> getSearchables() {
     return List.of();
   }
+
+  /**
+   * Called after a tab has been moved between two CTabFolders via 
drag-and-drop.
+   *
+   * @param sourceFolder the folder the tab was moved from
+   * @param targetFolder the folder the tab was moved to
+   */
+  default void onTabMovedBetweenFolders(CTabFolder sourceFolder, CTabFolder 
targetFolder) {
+    // Do nothing by default
+  }
+
+  /**
+   * Set the target folder that should receive new tabs (e.g. from a file 
drop).
+   *
+   * @param folder the folder that received the drop
+   */
+  default void setDropTargetFolder(CTabFolder folder) {
+    // Do nothing by default
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabClosable.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabClosable.java
index 299439d23b..838cddcd5c 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabClosable.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabClosable.java
@@ -30,12 +30,13 @@ public interface TabClosable {
 
   /** Get all the tabs on the right-hand side of the selected one */
   default List<CTabItem> getTabsToRight(CTabItem selectedTabItem) {
+    CTabFolder folder = selectedTabItem.getParent();
     List<CTabItem> items = new ArrayList<>();
-    for (int i = getTabFolder().getItems().length - 1; i >= 0; i--) {
-      if (selectedTabItem.equals(getTabFolder().getItems()[i])) {
+    for (int i = folder.getItems().length - 1; i >= 0; i--) {
+      if (selectedTabItem.equals(folder.getItems()[i])) {
         break;
       } else {
-        items.add(getTabFolder().getItems()[i]);
+        items.add(folder.getItems()[i]);
       }
     }
     return items;
@@ -43,8 +44,9 @@ public interface TabClosable {
 
   /** Get all the tabs on the left-hand side of the selected one */
   default List<CTabItem> getTabsToLeft(CTabItem selectedTabItem) {
+    CTabFolder folder = selectedTabItem.getParent();
     List<CTabItem> items = new ArrayList<>();
-    for (CTabItem item : getTabFolder().getItems()) {
+    for (CTabItem item : folder.getItems()) {
       if (selectedTabItem.equals(item)) {
         break;
       } else {
@@ -56,8 +58,9 @@ public interface TabClosable {
 
   /** Get all the other tabs of the selected one */
   default List<CTabItem> getOtherTabs(CTabItem selectedTabItem) {
+    CTabFolder folder = selectedTabItem.getParent();
     List<CTabItem> items = new ArrayList<>();
-    for (CTabItem item : getTabFolder().getItems()) {
+    for (CTabItem item : folder.getItems()) {
       if (!selectedTabItem.equals(item)) {
         items.add(item);
       }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabCloseHandler.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabCloseHandler.java
index 5dc0756b8e..71c8141518 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabCloseHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabCloseHandler.java
@@ -35,14 +35,17 @@ public class TabCloseHandler {
   CTabItem selectedItem;
 
   public TabCloseHandler(TabClosable tabClosablePerspective) {
-    this.tabFolder = tabClosablePerspective.getTabFolder();
+    this(tabClosablePerspective, tabClosablePerspective.getTabFolder());
+  }
+
+  public TabCloseHandler(TabClosable tabClosablePerspective, CTabFolder 
tabFolder) {
+    this.tabFolder = tabFolder;
 
     Menu menu = new Menu(tabFolder);
     tabFolder.setMenu(menu);
     tabFolder.addListener(SWT.MenuDetect, this::handleTabMenuDetectEvent);
     tabFolder.addListener(SWT.MouseUp, event -> handleMouseUp(event, 
tabClosablePerspective));
 
-    // Create menu item
     MenuItem miClose = new MenuItem(menu, SWT.NONE);
     miClose.setText(BaseMessages.getString(PKG, "HopGui.TabItem.Close.Text"));
     miClose.addListener(
@@ -62,7 +65,7 @@ public class TabCloseHandler {
     miCloseAll.addListener(
         SWT.Selection,
         event -> {
-          for (CTabItem tabItem : 
tabClosablePerspective.getTabFolder().getItems()) {
+          for (CTabItem tabItem : this.tabFolder.getItems()) {
             tabClosablePerspective.closeTab(null, tabItem);
           }
         });
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 442dfad312..c58d4cee86 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
@@ -103,6 +103,7 @@ public class TabItemReorder {
 
           @Override
           public void dragFinished(DragSourceEvent event) {
+            dragItem = null;
             if (EnvironmentUtils.getInstance().isWeb()) {
               return;
             }
@@ -207,6 +208,7 @@ public class TabItemReorder {
             }
             if (event.data instanceof String[] paths
                 && perspective instanceof IFileDropReceiver receiver) {
+              perspective.setDropTargetFolder(folder);
               receiver.openDroppedFiles(paths);
               return;
             }
@@ -281,38 +283,71 @@ public class TabItemReorder {
           }
 
           private boolean isDropSupported(CTabFolder folder, DropTargetEvent 
event) {
-            if (dragItem == null) {
+            if (dragItem != null && !dragItem.isDisposed()) {
+              Point point = 
folder.toControl(folder.getDisplay().getCursorLocation());
+              return folder.getItem(new Point(point.x, point.y)) != null;
+            }
+            return hasActiveTabTransfer(event);
+          }
+
+          private boolean hasActiveTabTransfer(DropTargetEvent event) {
+            if (event.dataTypes == null) {
               return false;
             }
-            Point point = 
folder.toControl(folder.getDisplay().getCursorLocation());
-            return folder.getItem(new Point(point.x, point.y)) != null;
+            for (TransferData td : event.dataTypes) {
+              if (TabTransfer.INSTANCE.isSupportedType(td)) {
+                return true;
+              }
+            }
+            return false;
           }
         });
   }
 
   private void moveTabs(CTabFolder folder, DropTargetEvent event) {
+    CTabItem sourceItem = this.dragItem;
+
+    if (sourceItem == null || sourceItem.isDisposed()) {
+      sourceItem = null;
+    }
+
+    if (sourceItem == null && event.data instanceof CTabItem transferredItem) {
+      if (!transferredItem.isDisposed()) {
+        sourceItem = transferredItem;
+      }
+    }
+
+    if (sourceItem == null) {
+      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 && dragItem != null) {
-      Control dragControl = dragItem.getControl();
-      String dragText = dragItem.getText();
-      Image dragImage = dragItem.getImage();
-      String dragToolTip = dragItem.getToolTipText();
-      boolean dragShowClose = dragItem.getShowClose();
-      Font dragFont = dragItem.getFont();
-      IHopFileTypeHandler dragFileTypeHandler = (IHopFileTypeHandler) 
dragItem.getData();
+    if (dropItem != null) {
+      Control dragControl = sourceItem.getControl();
+      String dragText = sourceItem.getText();
+      Image dragImage = sourceItem.getImage();
+      String dragToolTip = sourceItem.getToolTipText();
+      boolean dragShowClose = sourceItem.getShowClose();
+      Font dragFont = sourceItem.getFont();
+      IHopFileTypeHandler dragFileTypeHandler = (IHopFileTypeHandler) 
sourceItem.getData();
       IHopFileTypeHandler dropFileTypeHandler = (IHopFileTypeHandler) 
dropItem.getData();
 
       updateTabItemHandler(dragFileTypeHandler, dropItem);
-      updateTabItemHandler(dropFileTypeHandler, dragItem);
+      updateTabItemHandler(dropFileTypeHandler, sourceItem);
 
-      dragItem.setText(dropItem.getText());
-      dragItem.setImage(dropItem.getImage());
-      dragItem.setToolTipText(dropItem.getToolTipText());
-      dragItem.setFont(dropItem.getFont());
-      dragItem.setData(dropItem.getData());
-      dragItem.setShowClose(dropItem.getShowClose());
-      dragItem.setControl(dropItem.getControl());
+      sourceItem.setText(dropItem.getText());
+      sourceItem.setImage(dropItem.getImage());
+      sourceItem.setToolTipText(dropItem.getToolTipText());
+      sourceItem.setFont(dropItem.getFont());
+      sourceItem.setData(dropItem.getData());
+      sourceItem.setShowClose(dropItem.getShowClose());
+      sourceItem.setControl(dropItem.getControl());
 
       dropItem.setText(dragText);
       dropItem.setImage(dragImage);
@@ -326,6 +361,35 @@ public class TabItemReorder {
     }
   }
 
+  private void moveTabBetweenFolders(CTabItem srcItem, CTabFolder dstFolder) {
+    CTabFolder srcFolder = srcItem.getParent();
+    Control control = srcItem.getControl();
+    String text = srcItem.getText();
+    Image image = srcItem.getImage();
+    String tooltip = srcItem.getToolTipText();
+    Font font = srcItem.getFont();
+    IHopFileTypeHandler data = (IHopFileTypeHandler) srcItem.getData();
+    boolean showClose = srcItem.getShowClose();
+
+    control.setParent(dstFolder);
+
+    CTabItem newItem = new CTabItem(dstFolder, SWT.CLOSE);
+    newItem.setText(text);
+    newItem.setImage(image);
+    newItem.setToolTipText(tooltip);
+    newItem.setFont(font);
+    newItem.setData(data);
+    newItem.setShowClose(showClose);
+    newItem.setControl(control);
+
+    updateTabItemHandler(data, newItem);
+
+    srcItem.dispose();
+    dstFolder.setSelection(newItem);
+
+    perspective.onTabMovedBetweenFolders(srcFolder, dstFolder);
+  }
+
   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 4c94c99a04..3dd558e4e4 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
@@ -127,6 +127,7 @@ import org.eclipse.swt.dnd.FileTransfer;
 import org.eclipse.swt.graphics.Font;
 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.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
@@ -199,6 +200,12 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   private static final String EXPLORER_AUDIT_TYPE = 
"explorer-perspective-state";
   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;
   @Getter private GuiToolbarWidgets toolBarWidgets;
 
@@ -211,12 +218,17 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @Getter private Tree tree;
   private TreeEditor treeEditor;
   private CTabFolder tabFolder;
+  private CTabFolder tabFolder2;
+  private SashForm editorSash;
+  private CTabFolder activeTabFolder;
+  private boolean editorSplit;
   private Composite tabFolderWrapper;
   private Control toolBar;
   @Getter private GuiMenuWidgets menuWidgets;
   private final List<TabItemHandler> items;
   private boolean showingHiddenFiles;
 
+  private CTabItem splitMenuTargetTab;
   private boolean fileExplorerPanelVisible = true;
   @Getter private String rootFolder;
   @Getter private String rootName;
@@ -333,6 +345,51 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
     // Add key listeners
     HopGuiKeyHandler.getInstance().addParentObjectToHandle(this);
+
+    // Sync active tab with focused editor content so Save/shortcuts target 
the right tab in split
+    // view
+    ExplorerPerspective perspective = this;
+    parent
+        .getDisplay()
+        .addFilter(
+            SWT.FocusIn,
+            e -> {
+              if (!(e.widget instanceof Control)) {
+                return;
+              }
+              Control focusControl = (Control) e.widget;
+              if (hopGui.getActivePerspective() != perspective) {
+                return;
+              }
+              Control c = focusControl;
+              while (c != null) {
+                Object data = c.getData(KEY_TAB_FOLDER);
+                if (data == tabFolder || data == tabFolder2) {
+                  CTabFolder folder = (CTabFolder) data;
+                  for (CTabItem item : folder.getItems()) {
+                    if (item.getControl() == c) {
+                      if (activeTabFolder != folder || folder.getSelection() 
!= item) {
+                        activeTabFolder = folder;
+                        folder.setSelection(item);
+                        folder.showItem(item);
+                        Object handler = item.getData();
+                        if (handler instanceof IHopFileTypeHandler) {
+                          hopGui.handleFileCapabilities(
+                              ((IHopFileTypeHandler) handler).getFileType(),
+                              (IHopFileTypeHandler) handler,
+                              ((IHopFileTypeHandler) handler).hasChanged(),
+                              false,
+                              false);
+                        }
+                      }
+                      break;
+                    }
+                  }
+                  break;
+                }
+                c = c.getParent();
+              }
+            });
   }
 
   private void loadFileTypes() {
@@ -1147,18 +1204,29 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     tabFolderWrapper.setLayoutData(new FormDataBuilder().fullSize().result());
     PropsUi.setLook(tabFolderWrapper);
 
-    tabFolder = new CTabFolder(tabFolderWrapper, SWT.MULTI | SWT.BORDER);
-    tabFolder.setLayoutData(new FormDataBuilder().fullSize().result());
-    tabFolder.addListener(
+    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);
+  }
+
+  private CTabFolder createSingleTabFolder(Composite parent, boolean primary) {
+    CTabFolder folder = new CTabFolder(parent, SWT.MULTI | SWT.BORDER);
+    folder.setLayoutData(new FormDataBuilder().fullSize().result());
+    folder.addListener(
         SWT.Selection,
         e -> {
+          activeTabFolder = folder;
           updateGui();
-          // Notify zoom handler when tab is switched (for web/RAP)
-          if (org.apache.hop.ui.util.EnvironmentUtils.getInstance().isWeb()) {
+          if (EnvironmentUtils.getInstance().isWeb()) {
             notifyZoomHandlerForActiveTab();
           }
         });
-    tabFolder.addCTabFolder2Listener(
+    folder.addCTabFolder2Listener(
         new CTabFolder2Adapter() {
           @Override
           public void close(CTabFolderEvent event) {
@@ -1166,36 +1234,73 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
             closeTab(event, tabItem);
           }
         });
-    PropsUi.setLook(tabFolder, Props.WIDGET_STYLE_TAB);
+    PropsUi.setLook(folder, Props.WIDGET_STYLE_TAB);
+
+    folder.addListener(SWT.FocusIn, e -> activeTabFolder = folder);
+
+    if (primary) {
+      ToolBar tabToolBar = new ToolBar(folder, SWT.FLAT);
+      folder.setTopRight(tabToolBar, SWT.RIGHT);
+      PropsUi.setLook(tabToolBar);
+
+      final ToolItem item = new ToolItem(tabToolBar, SWT.PUSH);
+      item.setImage(GuiResource.getInstance().getImageMaximizePanel());
+      item.addListener(
+          SWT.Selection,
+          e -> {
+            if (sash.getMaximizedControl() == null) {
+              sash.setMaximizedControl(tabFolderWrapper);
+              item.setImage(GuiResource.getInstance().getImageMinimizePanel());
+            } else {
+              sash.setMaximizedControl(null);
+              item.setImage(GuiResource.getInstance().getImageMaximizePanel());
+            }
+          });
+      int height = tabToolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
+      folder.setTabHeight(Math.max(height, folder.getTabHeight()));
+    }
 
-    // Show/Hide tree
-    //
-    ToolBar tabToolBar = new ToolBar(tabFolder, SWT.FLAT);
-    tabFolder.setTopRight(tabToolBar, SWT.RIGHT);
-    PropsUi.setLook(tabToolBar);
+    new TabCloseHandler(this, folder);
+    new TabItemReorder(this, folder);
 
-    final ToolItem item = new ToolItem(tabToolBar, SWT.PUSH);
-    item.setImage(GuiResource.getInstance().getImageMaximizePanel());
-    item.addListener(
-        SWT.Selection,
+    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));
+        });
+
+    menu.addListener(
+        SWT.Show,
         e -> {
-          if (sash.getMaximizedControl() == null) {
-            sash.setMaximizedControl(tabFolderWrapper);
-            item.setImage(GuiResource.getInstance().getImageMinimizePanel());
+          if (!editorSplit || folder == tabFolder) {
+            miSplitMove.setText(
+                BaseMessages.getString(PKG, 
"ExplorerPerspective.TabMenu.MoveToRight"));
           } else {
-            sash.setMaximizedControl(null);
-            item.setImage(GuiResource.getInstance().getImageMaximizePanel());
+            miSplitMove.setText(
+                BaseMessages.getString(PKG, 
"ExplorerPerspective.TabMenu.MoveToLeft"));
           }
+          miSplitMove.setEnabled(splitMenuTargetTab != null);
         });
-    int height = tabToolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
-    tabFolder.setTabHeight(Math.max(height, tabFolder.getTabHeight()));
 
-    new TabCloseHandler(this);
+    miSplitMove.addListener(
+        SWT.Selection,
+        e -> {
+          if (splitMenuTargetTab != null && !splitMenuTargetTab.isDisposed()) {
+            splitOrMoveTab(splitMenuTargetTab);
+          }
+        });
 
-    // Support reorder tab item (also handles file drops when perspective 
implements
-    // IFileDropReceiver)
-    //
-    new TabItemReorder(this, tabFolder);
+    return folder;
+  }
+
+  private CTabFolder getTargetTabFolder() {
+    return activeTabFolder != null ? activeTabFolder : tabFolder;
   }
 
   @Override
@@ -1322,63 +1427,148 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
   @Override
   public void closeTab(CTabFolderEvent event, CTabItem tabItem) {
+    if (tabItem == null || tabItem.isDisposed()) {
+      return;
+    }
     IHopFileTypeHandler fileTypeHandler = (IHopFileTypeHandler) 
tabItem.getData();
-    boolean isRemoved = remove(fileTypeHandler);
+    boolean isRemoved = false;
+    if (fileTypeHandler != null) {
+      isRemoved = remove(fileTypeHandler);
+    }
+    // If remove failed (e.g. null/broken handler) or tab is still there, 
close it directly
+    if (!tabItem.isDisposed()) {
+      removeHandlerAndDisposeTab(tabItem);
+      isRemoved = true;
+    }
     if (!isRemoved && event != null) {
-      // Ignore event if canceled
       event.doit = false;
     }
   }
 
+  /**
+   * Remove any handler for this tab from the items list and dispose the tab. 
Used when the tab is
+   * broken (null handler or handler not in list) so the user can still close 
it.
+   */
+  private void removeHandlerAndDisposeTab(CTabItem tabItem) {
+    if (tabItem == null || tabItem.isDisposed()) {
+      return;
+    }
+    TabItemHandler toRemove = findHandlerByTabItem(tabItem);
+    if (toRemove != null) {
+      items.remove(toRemove);
+      IHopFileTypeHandler fileTypeHandler = toRemove.getTypeHandler();
+      if (fileTypeHandler != null && fileTypeHandler.getFilename() != null) {
+        hopGui.fileRefreshDelegate.remove(fileTypeHandler.getFilename());
+      }
+    }
+    tabItem.dispose();
+    if (!hopGui.fileDelegate.isClosing()) {
+      if (editorSplit && (tabFolder.getItemCount() == 0 || 
tabFolder2.getItemCount() == 0)) {
+        unsplitEditor();
+      }
+      if (tabFolder.getItemCount() == 0 && tabFolder2.getItemCount() == 0) {
+        HopGui.getInstance().handleFileCapabilities(new EmptyFileType(), 
false, false, false);
+      }
+      updateGui();
+    }
+  }
+
   private void removeTabItem(TabItemHandler item) {
+    if (item == null) {
+      return;
+    }
     items.remove(item);
-
-    // Close the tab
-    item.getTabItem().dispose();
-
-    // Remove the file in refreshDelegate
-    //
+    CTabItem tabItem = item.getTabItem();
+    if (tabItem != null && !tabItem.isDisposed()) {
+      tabItem.dispose();
+    }
     IHopFileTypeHandler fileTypeHandler = item.getTypeHandler();
-    if (fileTypeHandler.getFilename() != null) {
+    if (fileTypeHandler != null && fileTypeHandler.getFilename() != null) {
       hopGui.fileRefreshDelegate.remove(fileTypeHandler.getFilename());
     }
 
-    // Avoid refresh in a closing process (when switching project or exit)
     if (!hopGui.fileDelegate.isClosing()) {
 
-      // If all tab items are closed
-      //
-      if (tabFolder.getItemCount() == 0) {
+      if (editorSplit && (tabFolder.getItemCount() == 0 || 
tabFolder2.getItemCount() == 0)) {
+        unsplitEditor();
+      }
+
+      if (tabFolder.getItemCount() == 0 && tabFolder2.getItemCount() == 0) {
         HopGui.getInstance().handleFileCapabilities(new EmptyFileType(), 
false, false, false);
       }
 
-      // Update HopGui menu and toolbar
-      //
       this.updateGui();
     }
   }
 
   @Override
   public CTabFolder getTabFolder() {
-    return tabFolder;
+    return getTargetTabFolder();
+  }
+
+  /**
+   * 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
+   * 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);
+      }
+    }
+    return ordered;
+  }
+
+  private TabItemHandler findHandlerByTabItem(CTabItem tabItem) {
+    for (TabItemHandler h : items) {
+      if (h.getTabItem() == tabItem) {
+        return h;
+      }
+    }
+    return null;
+  }
+
+  /** Pane index for persistence: 0 = left (tabFolder), 1 = right 
(tabFolder2). */
+  public int getPaneIndexForTab(CTabItem tabItem) {
+    if (tabItem == null || tabItem.isDisposed()) {
+      return 0;
+    }
+    return tabItem.getParent() == tabFolder2 ? 1 : 0;
+  }
+
+  /** The right-hand tab folder when split; used by audit restore to target 
the correct pane. */
+  public CTabFolder getRightTabFolder() {
+    return tabFolder2;
   }
 
   public void addFile(IExplorerFileTypeHandler fileTypeHandler) {
 
-    // Select and show tab item
-    //
     TabItemHandler handler =
         this.findTabItemHandler(fileTypeHandler.getFilename(), 
fileTypeHandler.getFileType());
     if (handler != null) {
-      tabFolder.setSelection(handler.getTabItem());
-      tabFolder.showItem(handler.getTabItem());
-      tabFolder.setFocus();
+      CTabFolder owningFolder = handler.getTabItem().getParent();
+      owningFolder.setSelection(handler.getTabItem());
+      owningFolder.showItem(handler.getTabItem());
+      owningFolder.setFocus();
+      activeTabFolder = owningFolder;
       return;
     }
 
-    // Create tab item
-    //
-    CTabItem tabItem = new CTabItem(tabFolder, SWT.CLOSE);
+    CTabFolder targetFolder = getTargetTabFolder();
+
+    CTabItem tabItem = new CTabItem(targetFolder, SWT.CLOSE);
     tabItem.setFont(GuiResource.getInstance().getFontDefault());
     String displayName = getTabDisplayName(fileTypeHandler);
     tabItem.setText(Const.NVL(displayName, ""));
@@ -1386,8 +1576,6 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     tabItem.setImage(getFileTypeImage(fileTypeHandler.getFileType()));
     tabItem.setData(fileTypeHandler);
 
-    // Set the tab bold if the file has changed and vice-versa
-    //
     fileTypeHandler.addContentChangedListener(
         new IContentChangedListener() {
           @Override
@@ -1397,13 +1585,11 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
           @Override
           public void contentSafe(Object parentObject) {
-            tabItem.setFont(tabFolder.getFont());
+            tabItem.setFont(tabItem.getParent().getFont());
           }
         });
 
-    // Create file content area
-    //
-    Composite composite = new Composite(tabFolder, SWT.NONE);
+    Composite composite = new Composite(targetFolder, SWT.NONE);
     FormLayout layoutComposite = new FormLayout();
     layoutComposite.marginWidth = PropsUi.getFormMargin();
     layoutComposite.marginHeight = PropsUi.getFormMargin();
@@ -1411,21 +1597,17 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     composite.setLayoutData(new FormDataBuilder().fullSize().result());
     PropsUi.setLook(composite);
 
-    // This is usually done by the file type
-    //
     fileTypeHandler.renderFile(composite);
 
+    composite.setData(KEY_TAB_FOLDER, targetFolder);
     tabItem.setControl(composite);
 
     items.add(new TabItemHandler(tabItem, fileTypeHandler));
 
     hopGui.fileRefreshDelegate.register(fileTypeHandler.getFilename(), 
fileTypeHandler);
 
-    // Switch to the tab
-    //
-    tabFolder.setSelection(tabItem);
+    targetFolder.setSelection(tabItem);
 
-    // Add key listeners
     HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
     keyHandler.addParentObjectToHandle(this);
     HopGui.getInstance().replaceKeyboardShortcutListeners(this.getShell(), 
keyHandler);
@@ -1441,50 +1623,48 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
    */
   public IHopFileTypeHandler addPipeline(PipelineMeta pipelineMeta) throws 
HopException {
 
-    // Select and show the tab item (If it's a new pipeline, the file name 
will be null)
-    //
     TabItemHandler handler = 
this.findTabItemHandler(pipelineMeta.getFilename());
     if (handler != null) {
-      tabFolder.setSelection(handler.getTabItem());
-      tabFolder.showItem(handler.getTabItem());
-      tabFolder.setFocus();
+      CTabFolder owningFolder = handler.getTabItem().getParent();
+      owningFolder.setSelection(handler.getTabItem());
+      owningFolder.showItem(handler.getTabItem());
+      owningFolder.setFocus();
+      activeTabFolder = owningFolder;
       return handler.getTypeHandler();
     }
 
-    // Create the pipeline graph
-    //
+    CTabFolder targetFolder = getTargetTabFolder();
+
     HopGuiPipelineGraph pipelineGraph =
-        new HopGuiPipelineGraph(tabFolder, hopGui, this, pipelineMeta, 
pipelineFileType);
+        new HopGuiPipelineGraph(targetFolder, hopGui, this, pipelineMeta, 
pipelineFileType);
 
-    // Assign the control to the tab
-    //
-    CTabItem tabItem = new CTabItem(tabFolder, SWT.CLOSE);
+    CTabItem tabItem = new CTabItem(targetFolder, SWT.CLOSE);
     tabItem.setFont(GuiResource.getInstance().getFontDefault());
     tabItem.setImage(GuiResource.getInstance().getImagePipeline());
     tabItem.setText(Const.NVL(pipelineGraph.getName(), "<>"));
     tabItem.setToolTipText(pipelineGraph.getFilename());
+    pipelineGraph.setData(KEY_TAB_FOLDER, targetFolder);
     tabItem.setControl(pipelineGraph);
     tabItem.setData(pipelineGraph);
+    pipelineGraph.addListener(
+        SWT.Show,
+        e -> {
+          if (!pipelineGraph.isDisposed()) {
+            pipelineGraph.redraw();
+          }
+        });
 
     items.add(new TabItemHandler(tabItem, pipelineGraph));
 
-    // If it's a new pipeline, the file name will be null. So, ignore
-    //
     if (pipelineMeta.getFilename() != null) {
       hopGui.fileRefreshDelegate.register(pipelineMeta.getFilename(), 
pipelineGraph);
     }
 
-    // Update the internal variables (file specific) in the pipeline graph 
variables
-    //
     pipelineMeta.setInternalHopVariables(pipelineGraph.getVariables());
 
-    // Update the variables using the list of parameters
-    //
     hopGui.setParametersAsVariablesInUI(pipelineMeta, 
pipelineGraph.getVariables());
 
-    // Switch to the tab
-    //
-    tabFolder.setSelection(tabItem);
+    targetFolder.setSelection(tabItem);
 
     try {
       ExtensionPointHandler.callExtensionPoint(
@@ -1500,7 +1680,6 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           e);
     }
 
-    // Add key listeners
     HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
     keyHandler.addParentObjectToHandle(this);
     HopGui.getInstance().replaceKeyboardShortcutListeners(this.getShell(), 
keyHandler);
@@ -1518,46 +1697,48 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
    */
   public IHopFileTypeHandler addWorkflow(WorkflowMeta workflowMeta) throws 
HopException {
 
-    // Select and show the tab item (If it's a new workflow, the file name 
will be null)
-    //
     TabItemHandler handler = 
this.findTabItemHandler(workflowMeta.getFilename());
     if (handler != null) {
-      tabFolder.setSelection(handler.getTabItem());
-      tabFolder.showItem(handler.getTabItem());
-      tabFolder.setFocus();
+      CTabFolder owningFolder = handler.getTabItem().getParent();
+      owningFolder.setSelection(handler.getTabItem());
+      owningFolder.showItem(handler.getTabItem());
+      owningFolder.setFocus();
+      activeTabFolder = owningFolder;
       return handler.getTypeHandler();
     }
 
+    CTabFolder targetFolder = getTargetTabFolder();
+
     HopGuiWorkflowGraph workflowGraph =
-        new HopGuiWorkflowGraph(tabFolder, hopGui, this, workflowMeta, 
workflowFileType);
+        new HopGuiWorkflowGraph(targetFolder, hopGui, this, workflowMeta, 
workflowFileType);
 
-    CTabItem tabItem = new CTabItem(tabFolder, SWT.CLOSE);
+    CTabItem tabItem = new CTabItem(targetFolder, SWT.CLOSE);
     tabItem.setFont(GuiResource.getInstance().getFontDefault());
     tabItem.setImage(GuiResource.getInstance().getImageWorkflow());
     tabItem.setText(Const.NVL(workflowGraph.getName(), "<>"));
     tabItem.setToolTipText(workflowGraph.getFilename());
+    workflowGraph.setData(KEY_TAB_FOLDER, targetFolder);
     tabItem.setControl(workflowGraph);
     tabItem.setData(workflowGraph);
+    workflowGraph.addListener(
+        SWT.Show,
+        e -> {
+          if (!workflowGraph.isDisposed()) {
+            workflowGraph.redraw();
+          }
+        });
 
     items.add(new TabItemHandler(tabItem, workflowGraph));
 
-    // If it's a new workflow, the file name will be null
-    //
     if (workflowMeta.getFilename() != null) {
       hopGui.fileRefreshDelegate.register(workflowMeta.getFilename(), 
workflowGraph);
     }
 
-    // Update the internal variables (file specific) in the workflow graph 
variables
-    //
     workflowMeta.setInternalHopVariables(workflowGraph.getVariables());
 
-    // Update the variables using the list of parameters
-    //
     hopGui.setParametersAsVariablesInUI(workflowMeta, 
workflowGraph.getVariables());
 
-    // Switch to the tab
-    //
-    tabFolder.setSelection(tabItem);
+    targetFolder.setSelection(tabItem);
 
     try {
       ExtensionPointHandler.callExtensionPoint(
@@ -1573,7 +1754,6 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           e);
     }
 
-    // Add key listeners
     HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
     keyHandler.addParentObjectToHandle(this);
     HopGui.getInstance().replaceKeyboardShortcutListeners(this.getShell(), 
keyHandler);
@@ -1675,30 +1855,43 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
   @Override
   public IHopFileTypeHandler getActiveFileTypeHandler() {
-    if (tabFolder.getSelectionIndex() < 0) {
+    CTabFolder active = getTargetTabFolder();
+    if (active.getSelectionIndex() < 0) {
+      CTabFolder other = (active == tabFolder) ? tabFolder2 : tabFolder;
+      if (other.getSelectionIndex() >= 0) {
+        return (IHopFileTypeHandler) other.getSelection().getData();
+      }
       return new EmptyHopFileTypeHandler();
     }
-    return (IHopFileTypeHandler) tabFolder.getSelection().getData();
+    return (IHopFileTypeHandler) active.getSelection().getData();
   }
 
   @Override
   public void setActiveFileTypeHandler(IHopFileTypeHandler fileTypeHandler) {
-    for (CTabItem item : tabFolder.getItems()) {
-      if (item.getData().equals(fileTypeHandler)) {
-        tabFolder.setSelection(item);
-        tabFolder.showItem(item);
-
-        HopGui.getInstance()
-            .handleFileCapabilities(
-                fileTypeHandler.getFileType(),
-                fileTypeHandler,
-                fileTypeHandler.hasChanged(),
-                false,
-                false);
+    for (CTabFolder folder : getTabFolders()) {
+      for (CTabItem item : folder.getItems()) {
+        if (item.getData().equals(fileTypeHandler)) {
+          folder.setSelection(item);
+          folder.showItem(item);
+          activeTabFolder = folder;
+
+          HopGui.getInstance()
+              .handleFileCapabilities(
+                  fileTypeHandler.getFileType(),
+                  fileTypeHandler,
+                  fileTypeHandler.hasChanged(),
+                  false,
+                  false);
+          return;
+        }
       }
     }
   }
 
+  private List<CTabFolder> getTabFolders() {
+    return List.of(tabFolder, tabFolder2);
+  }
+
   @GuiMenuElement(
       root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
       parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
@@ -1804,7 +1997,8 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @GuiKeyboardShortcut(alt = true, key = SWT.F1)
   @GuiOsxKeyboardShortcut(alt = true, key = SWT.F1)
   public void selectInTree() {
-    if (tabFolder.getSelectionIndex() >= 0) {
+    CTabFolder active = getTargetTabFolder();
+    if (active.getSelectionIndex() >= 0) {
       this.selectInTree(getActiveFileTypeHandler().getFilename());
     }
   }
@@ -2333,7 +2527,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
         Font font =
             fileTypeHandler.hasChanged()
                 ? GuiResource.getInstance().getFontBold()
-                : tabFolder.getFont();
+                : tabItem.getParent().getFont();
         tabItem.setFont(font);
       }
     }
@@ -2546,12 +2740,15 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
    */
   @Override
   public boolean remove(IHopFileTypeHandler fileTypeHandler) {
-    if (fileTypeHandler.isCloseable()) {
-      TabItemHandler item = this.getTabItemHandler(fileTypeHandler);
-      removeTabItem(item);
-      return true;
+    if (fileTypeHandler == null || !fileTypeHandler.isCloseable()) {
+      return false;
     }
-    return false;
+    TabItemHandler item = this.getTabItemHandler(fileTypeHandler);
+    if (item == null) {
+      return true; // Allow close to proceed; closeTab will dispose the tab 
directly
+    }
+    removeTabItem(item);
+    return true;
   }
 
   @Override
@@ -2562,11 +2759,12 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @Override
   public void navigateToPreviousFile() {
     if (hasNavigationPreviousFile()) {
-      int index = tabFolder.getSelectionIndex() - 1;
+      CTabFolder active = getTargetTabFolder();
+      int index = active.getSelectionIndex() - 1;
       if (index < 0) {
-        index = tabFolder.getItemCount() - 1;
+        index = active.getItemCount() - 1;
       }
-      tabFolder.setSelection(index);
+      active.setSelection(index);
       updateGui();
     }
   }
@@ -2574,23 +2772,24 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @Override
   public void navigateToNextFile() {
     if (hasNavigationNextFile()) {
-      int index = tabFolder.getSelectionIndex() + 1;
-      if (index >= tabFolder.getItemCount()) {
+      CTabFolder active = getTargetTabFolder();
+      int index = active.getSelectionIndex() + 1;
+      if (index >= active.getItemCount()) {
         index = 0;
       }
-      tabFolder.setSelection(index);
+      active.setSelection(index);
       updateGui();
     }
   }
 
   @Override
   public boolean hasNavigationPreviousFile() {
-    return tabFolder.getItemCount() > 1;
+    return getTargetTabFolder().getItemCount() > 1;
   }
 
   @Override
   public boolean hasNavigationNextFile() {
-    return tabFolder.getItemCount() > 1;
+    return getTargetTabFolder().getItemCount() > 1;
   }
 
   @Override
@@ -2654,15 +2853,124 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     fileExplorerPanelVisible = !fileExplorerPanelVisible;
 
     if (fileExplorerPanelVisible) {
-      // Show the file explorer panel - restore normal layout
       sash.setMaximizedControl(null);
       sash.setWeights(20, 80);
     } else {
-      // Hide the file explorer panel - maximize the tab folder area
       sash.setMaximizedControl(tabFolderWrapper);
     }
   }
 
+  /** 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();
+    String tooltip = srcItem.getToolTipText();
+    Font font = srcItem.getFont();
+    Object data = srcItem.getData();
+    boolean showClose = srcItem.getShowClose();
+    Control control = srcItem.getControl();
+
+    // Detach control from the old tab item before disposing it. This prevents 
CTabFolder from
+    // hiding the control during srcItem.dispose().
+    srcItem.setControl(null);
+    srcItem.dispose();
+
+    // Reparent the control to the destination folder (mirrors addPipeline 
where the graph is
+    // created with targetFolder as its parent).
+    control.setParent(dstFolder);
+    control.setData(KEY_TAB_FOLDER, dstFolder);
+
+    // Create the new tab item and wire it up — same order as 
addPipeline/addWorkflow.
+    CTabItem newItem = new CTabItem(dstFolder, SWT.CLOSE);
+    newItem.setText(text);
+    newItem.setImage(image);
+    newItem.setToolTipText(tooltip);
+    newItem.setFont(font);
+    newItem.setControl(control);
+    newItem.setData(data);
+    newItem.setShowClose(showClose);
+
+    for (TabItemHandler handler : items) {
+      if (handler.getTabItem() == srcItem) {
+        handler.setTabItem(newItem);
+        break;
+      }
+    }
+  }
+
+  /** Move a tab to the other split pane. If not currently split, creates the 
split first. */
+  private void splitOrMoveTab(CTabItem tab) {
+    CTabFolder sourceFolder = tab.getParent();
+    CTabFolder targetFolder;
+
+    if (!editorSplit) {
+      splitEditor();
+      targetFolder = tabFolder2;
+    } else {
+      targetFolder = (sourceFolder == tabFolder) ? tabFolder2 : tabFolder;
+    }
+
+    moveTabToFolder(tab, targetFolder);
+    targetFolder.setSelection(targetFolder.getItemCount() - 1);
+    activeTabFolder = targetFolder;
+
+    if (editorSplit && sourceFolder.getItemCount() == 0) {
+      unsplitEditor();
+    }
+
+    // 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.
+    CTabItem sel = targetFolder.getSelection();
+    if (sel != null && sel.getControl() != null && 
!sel.getControl().isDisposed()) {
+      sel.getControl().setFocus();
+    }
+    updateGui();
+  }
+
+  @Override
+  public void onTabMovedBetweenFolders(CTabFolder sourceFolder, CTabFolder 
targetFolder) {
+    activeTabFolder = targetFolder;
+    if (editorSplit && sourceFolder.getItemCount() == 0) {
+      unsplitEditor();
+    }
+  }
+
+  @Override
+  public void setDropTargetFolder(CTabFolder folder) {
+    if (folder == tabFolder || folder == tabFolder2) {
+      activeTabFolder = folder;
+    }
+  }
+
   /**
    * Check if the file explorer panel is currently visible.
    *
@@ -2680,6 +2988,19 @@ 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])));
+        }
+      }
       AuditManager.getActive()
           .saveAuditStateMap(HopNamespace.getNamespace(), EXPLORER_AUDIT_TYPE, 
stateMap);
     } catch (Exception e) {
@@ -2688,7 +3009,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   }
 
   /**
-   * Restore file explorer panel visibility from saved audit state.
+   * Load saved file explorer panel visibility from saved audit state.
    *
    * @return the saved visibility, or null if no saved state exists
    */
@@ -2713,6 +3034,54 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     return null;
   }
 
+  /**
+   * 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.
+   */
+  public void applyRestoredEditorSplitState() {
+    if (editorSash == null || editorSash.isDisposed()) {
+      return;
+    }
+    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);
+            }
+          } else {
+            editorSash.setWeights(50, 50);
+          }
+          editorSash.layout(true);
+        }
+      }
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error restoring explorer editor split state", 
e);
+    }
+  }
+
   /**
    * Load saved file explorer panel visibility for the current namespace and 
apply it. Call this
    * after startup (e.g. from HopGui open() async block) and when the project 
changes
@@ -2732,6 +3101,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
         sash.setMaximizedControl(tabFolderWrapper);
       }
     }
+    applyRestoredEditorSplitState();
   }
 
   public static class DetermineRootFolderExtension {
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 c94ecf6a0a..51f42c6cf6 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
@@ -58,3 +58,5 @@ ExplorerPerspective.ToolbarElement.ExpandAll.Tooltip=Expand 
all folders
 ExplorerPerspective.ToolbarElement.CollapseAll.Tooltip=Collapse all folders
 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

Reply via email to