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