From 5d45e1e1f29426d4e56985ca75ac639c4a3a131a Mon Sep 17 00:00:00 2001 From: Hans Van Akelyen Date: Wed, 1 Jul 2026 15:15:36 +0200 Subject: [PATCH 1/3] more options for splitting tabs and moving to external view, fixes #6708 --- .../static/src/main/resources/hop-gui.bat | 4 +- .../static/src/main/resources/hop-gui.sh | 3 +- .../apache/hop/ui/core/gui/GuiResource.java | 5 + .../java/org/apache/hop/ui/hopgui/HopGui.java | 19 + .../hopgui/delegates/HopGuiAuditDelegate.java | 31 +- .../file/pipeline/HopGuiPipelineGraph.java | 184 ++++- .../file/workflow/HopGuiWorkflowGraph.java | 180 ++++- .../hopgui/perspective/IHopPerspective.java | 28 + .../ui/hopgui/perspective/TabItemReorder.java | 364 ++++++++- .../explorer/ExplorerPerspective.java | 764 ++++++++++++++---- .../metadata/MetadataPerspective.java | 62 +- .../ui/hopgui/terminal/HopGuiBottomDock.java | 44 + .../messages/messages_en_US.properties | 3 + .../hopgui/messages/messages_en_US.properties | 3 + .../messages/messages_en_US.properties | 1 + .../main/resources/ui/images/detach-panel.svg | 4 + .../main/resources/ui/images/dock-panel.svg | 4 + 17 files changed, 1430 insertions(+), 273 deletions(-) create mode 100644 ui/src/main/resources/ui/images/detach-panel.svg create mode 100644 ui/src/main/resources/ui/images/dock-panel.svg diff --git a/assemblies/static/src/main/resources/hop-gui.bat b/assemblies/static/src/main/resources/hop-gui.bat index fa830f4630c..e6ef8c93e7f 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 69f3c6a4b82..ec2dbc546f1 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 78bea972f9f..f92f8573e8b 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 @@ -220,6 +220,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; @@ -760,6 +762,9 @@ private void loadCommonImages() { 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 c0163521e7a..f04630659a2 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.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 void notifyWebThemePreferenceChanged(boolean darkMode) { 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 50bf6c5dab1..a075a66a3f1 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.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 void openLastFiles() { 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 void openLastFiles() { 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 void writeLastOpenFiles() { 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 522a8cbc374..3d6edc3ad87 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 @@ private void disposeExtraView() { 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 @@ private void rotateExtraView() { 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 void addExtraView() { 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 void mouseDoubleClick(MouseEvent arg0) { 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 1efd2fe44c4..7eb0eaab800 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 void saveAs(String filename) throws HopException { /** 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 void addExtraView() { 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 void mouseDoubleClick(MouseEvent arg0) { 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 @@ private void disposeExtraView() { 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 d1a4d6b0175..3d5c84836bc 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 @@ default void setDropTargetFolder(CTabFolder folder) { // 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 dd7e2935e63..23354a60dd6 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.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 TabItemReorder(IHopPerspective perspective, CTabFolder folder) { @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 void dragFinished(DragSourceEvent event) { @Override public void dragEnter(DropTargetEvent event) { + lastDragOverZone = IHopPerspective.DROP_ZONE_CENTER; isFileDrop = isFileTransferType(event); isMetadataDrop = isMetadataTransferType(event); if (isFileDrop) { @@ -162,10 +214,7 @@ public void dragEnter(DropTargetEvent event) { @Override public void dragLeave(DropTargetEvent event) { handleDragEvent(event); - if (dropTargetTab != null) { - dropTargetTab = null; - folder.redraw(); - } + clearDropFeedback(folder); } @Override @@ -197,28 +246,49 @@ public void dragOver(DropTargetEvent event) { 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 void drop(DropTargetEvent event) { 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 @@ private void handleDragEvent(DropTargetEvent event) { 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 @@ private TransferData getMetadataTransferDataType(DropTargetEvent event) { }); } - 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 @@ private void moveTabs(CTabFolder folder, DropTargetEvent event) { 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,197 @@ private void moveTabBetweenFolders(CTabItem srcItem, CTabFolder dstFolder) { 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) { + 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 3f968513f6b..40b1a2a141e 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 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 folderByLeafId = new HashMap<>(); + private CTabFolder activeTabFolder; - private boolean editorSplit; private Composite tabFolderWrapper; private Control toolBar; @Getter private GuiMenuWidgets menuWidgets; @@ -397,7 +428,7 @@ public void initialize(HopGui hopGui, Composite parent) { 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 @@ protected void createTabFolder(Composite parent) { 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) { @@ -1851,17 +1877,17 @@ public void close(CTabFolderEvent event) { 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) { - miSplitMove.setText( - BaseMessages.getString(PKG, "ExplorerPerspective.TabMenu.MoveToRight")); - } else { - miSplitMove.setText( - BaseMessages.getString(PKG, "ExplorerPerspective.TabMenu.MoveToLeft")); - } - miSplitMove.setEnabled(splitMenuTargetTab != null); + miSplitMove.setText( + BaseMessages.getString(PKG, "ExplorerPerspective.TabMenu.MoveToRight")); + // 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( @@ -1872,11 +1898,23 @@ public void close(CTabFolderEvent event) { } }); + 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 folders = getTabFolders(); + return folders.isEmpty() ? null : folders.get(0); } @Override @@ -2074,10 +2112,8 @@ private void removeHandlerAndDisposeTab(CTabItem tabItem) { } 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 +2136,9 @@ private void removeTabItem(TabItemHandler item) { 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 +2152,21 @@ public CTabFolder getTabFolder() { } /** - * 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 getTabItemHandlersInPaneOrder() { List 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 +2178,30 @@ private TabItemHandler findHandlerByTabItem(CTabItem tabItem) { 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) { @@ -2538,8 +2578,11 @@ public IHopFileTypeHandler getActiveFileTypeHandler() { } 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(); @@ -2609,8 +2652,208 @@ private String getFilenameForTab(CTabItem tabItem) { 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 getTabFolders() { - return List.of(tabFolder, tabFolder2); + List 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 getDockedTabFolders() { + List result = new ArrayList<>(); + collectTabFolders(editorRoot, result); + return result; + } + + private boolean isDockedTabFolder(CTabFolder folder) { + return getDockedTabFolders().contains(folder); + } + + private void collectTabFolders(Control node, List 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 nodeChildren(SashForm sashForm) { + List 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 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 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( @@ -4020,36 +4263,6 @@ public void toggleFileExplorerPanel() { } } - /** 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(); @@ -4087,25 +4300,19 @@ private void moveTabToFolder(CTabItem srcItem, CTabFolder dstFolder) { } } - /** 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. @@ -4119,18 +4326,153 @@ private void splitOrMoveTab(CTabItem tab) { @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 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 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. * @@ -4154,18 +4496,12 @@ public void saveExplorerStateOnShutdown() { 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 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); @@ -4201,51 +4537,178 @@ private Boolean restoreFileExplorerPanelVisibility() { } /** - * 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 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 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 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 root = (Map) 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 serializeEditorLayout() { + if (editorRoot == null || editorRoot.isDisposed()) { + return null; + } + return serializeLayoutNode(editorRoot, new int[] {0}); + } + + private Map serializeLayoutNode(Control node, int[] leafCounter) { + Map 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> children = new ArrayList<>(); + for (Control child : nodeChildren(sashForm)) { + children.add(serializeLayoutNode(child, leafCounter)); + } + map.put(LAYOUT_CHILDREN, children); + List 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 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 child = (Map) 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; } /** @@ -4268,6 +4731,9 @@ public void applyRestoredState() { } } 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 56a4eb1d9bc..3589700252f 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 void onNewMetadataType() { } /** - * Adds one "New <type>" 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 <type>" 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 categoryIds = new ArrayList<>(); @@ -1098,29 +1100,49 @@ private void addNewTypeMenuItems(Menu menu, String onlyCategoryId) { 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 <type>" 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 1c50574deb4..4cdba0f3232 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 @@ private void saveOpenTerminals() { 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 void restoreTerminals() { 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 void restoreTerminals() { } } + /** + * 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 e1e59c8624a..a9f77fcb39d 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 e1a614bfed1..5e9c135f1e5 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 8290f425166..ab2c2893861 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 00000000000..6a2d598a755 --- /dev/null +++ b/ui/src/main/resources/ui/images/detach-panel.svg @@ -0,0 +1,4 @@ + + + + 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 00000000000..a191b9bc1ad --- /dev/null +++ b/ui/src/main/resources/ui/images/dock-panel.svg @@ -0,0 +1,4 @@ + + + + From 3e334ca4cffbc61c184217156e2b874c60e4481b Mon Sep 17 00:00:00 2001 From: Hans Van Akelyen Date: Mon, 20 Jul 2026 12:35:02 +0200 Subject: [PATCH 2/3] block in hop web --- .../ui/hopgui/perspective/TabItemReorder.java | 6 ++ .../explorer/ExplorerPerspective.java | 77 ++++++++++--------- 2 files changed, 47 insertions(+), 36 deletions(-) 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 23354a60dd6..5ccb0570693 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 @@ -619,6 +619,12 @@ private boolean isUnambiguousTabDrop(CTabFolder folder, int zone, Point dropPoin * 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; 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 40b1a2a141e..15890eb82e1 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 @@ -1865,46 +1865,51 @@ public void close(CTabFolderEvent event) { 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")); - - folder.addListener( - SWT.MenuDetect, - event -> { - Point pt = folder.toControl(folder.getDisplay().getCursorLocation()); - splitMenuTargetTab = folder.getItem(new Point(pt.x, pt.y)); - }); + // 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)); + }); - MenuItem miDetach = new MenuItem(menu, SWT.NONE); - miDetach.setText(BaseMessages.getString(PKG, "ExplorerPerspective.TabMenu.MoveToNewWindow")); + MenuItem miDetach = new MenuItem(menu, SWT.NONE); + miDetach.setText(BaseMessages.getString(PKG, "ExplorerPerspective.TabMenu.MoveToNewWindow")); - menu.addListener( - SWT.Show, - e -> { - miSplitMove.setText( - BaseMessages.getString(PKG, "ExplorerPerspective.TabMenu.MoveToRight")); - // Splitting only makes sense if the folder keeps at least one tab behind. - miSplitMove.setEnabled(splitMenuTargetTab != null && folder.getItemCount() > 1); - miDetach.setEnabled(splitMenuTargetTab != null); - }); + menu.addListener( + SWT.Show, + e -> { + miSplitMove.setText( + BaseMessages.getString(PKG, "ExplorerPerspective.TabMenu.MoveToRight")); + // 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); - } - }); + miDetach.addListener( + SWT.Selection, + e -> { + if (splitMenuTargetTab != null && !splitMenuTargetTab.isDisposed()) { + detachTabToWindow(splitMenuTargetTab); + } + }); + } return folder; } From bedba3fd3a743117552966f0fc6652c9751d0afe Mon Sep 17 00:00:00 2001 From: mattcasters Date: Mon, 20 Jul 2026 17:32:08 +0200 Subject: [PATCH 3/3] Fix ExecProcess command-line tokenization after #7565 Match historical Runtime.exec(String) whitespace tokenization via StringTokenizer before exec(String[]), so single-command fields like "/bin/echo hop-single" work again. Based on @leehaut's fix in #7581. --- .../transforms/execprocess/ExecProcess.java | 19 ++++++++++++++++++- .../execprocess/ExecProcessTest.java | 10 ++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/plugins/transforms/execprocess/src/main/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcess.java b/plugins/transforms/execprocess/src/main/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcess.java index d275946a6a1..922ba648033 100644 --- a/plugins/transforms/execprocess/src/main/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcess.java +++ b/plugins/transforms/execprocess/src/main/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcess.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.StringTokenizer; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CountDownLatch; @@ -218,7 +219,10 @@ private void execProcess(String[] process, ProcessResult processresult) throws H // execute process try { if (!meta.isArgumentsInFields()) { - p = data.runtime.exec(new String[] {process[0]}); + // Match historical Runtime.exec(String) whitespace tokenization without using the + // deprecated String overload. A single-element String[] would treat the whole command + // line (e.g. "/bin/echo hop-single") as the executable name. + p = data.runtime.exec(tokenizeCommandLine(process[0])); } else { p = data.runtime.exec(process); } @@ -323,6 +327,19 @@ private void execProcess(String[] process, ProcessResult processresult) throws H } } + /** + * Tokenize a command line the same way Runtime.exec(String) historically did (whitespace via + * {@link StringTokenizer}). + */ + static String[] tokenizeCommandLine(String command) { + StringTokenizer st = new StringTokenizer(command); + String[] cmdArray = new String[st.countTokens()]; + for (int i = 0; st.hasMoreTokens(); i++) { + cmdArray[i] = st.nextToken(); + } + return cmdArray; + } + private String getOutputString(BufferedReader b) throws IOException { StringBuilder returnValueBuffer = new StringBuilder(); String line; diff --git a/plugins/transforms/execprocess/src/test/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcessTest.java b/plugins/transforms/execprocess/src/test/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcessTest.java index 7cfd1e7c7a4..832673cbcde 100644 --- a/plugins/transforms/execprocess/src/test/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcessTest.java +++ b/plugins/transforms/execprocess/src/test/java/org/apache/hop/pipeline/transforms/execprocess/ExecProcessTest.java @@ -25,6 +25,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import java.util.Arrays; +import java.util.List; import org.apache.hop.core.HopEnvironment; import org.apache.hop.core.IRowSet; import org.apache.hop.core.QueueRowSet; @@ -215,6 +217,14 @@ void processRow_failWhenNotSuccess_setsErrors() throws HopException { assertEquals(1, transform.getErrors()); } + @Test + void tokenizeCommandLine_splitsOnWhitespaceLikeRuntimeExecString() { + assertEquals( + Arrays.asList("/bin/echo", "hop-single"), + Arrays.asList(ExecProcess.tokenizeCommandLine("/bin/echo hop-single"))); + assertEquals(List.of("cmd"), Arrays.asList(ExecProcess.tokenizeCommandLine("cmd"))); + } + @Test void processRow_emptyProcessField_throws() throws HopException { HopEnvironment.init();