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 c294e9ec4d add terminal widget to hop gui #5938 (#6576)
c294e9ec4d is described below
commit c294e9ec4d68bbe228f10481a52ca72f98ea1d96
Author: Bart Maertens <[email protected]>
AuthorDate: Mon Feb 16 11:58:31 2026 +0000
add terminal widget to hop gui #5938 (#6576)
* updated terminal tabs, refactored terminal ui and menu
* hop gui terminal, working initial version. #5938
* fallback to simple terminal is jedi terminal is not available. #5938
* updated config options, color schemes on Windows. #5938
* terminal minor updates and cleanup. #5938
* store and restore terminal type in state. #5938
* windows terminal color updates. #5938
* windows terminal color updates. #5938
* windows terminal color updates. #5938
* match powershell colors in windows jedit terminal. #5938
* exclude windows powershell background from toolbar. #5938
* simplified layout for terminal and execution panel. #5938
* updates after resolving merge conflict
* updated bottom toolbar layout. #5938
* terminal widget simplifications. #5938
* code cleanup. #5938
* code cleanup. #5938
* comments cleanup. #5938
* conditionally show/hide bottom left toolbar buttons. #5938
---
.../apache/hop/projects/gui/ProjectsGuiPlugin.java | 12 +
ui/pom.xml | 38 ++
.../main/java/org/apache/hop/ui/core/PropsUi.java | 1 +
.../org/apache/hop/ui/core/gui/GuiResource.java | 2 +
.../main/java/org/apache/hop/ui/hopgui/HopGui.java | 721 ++++++++++++++-------
.../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java | 52 +-
.../ui/hopgui/SidebarToolbarItemDescriptor.java | 74 +++
.../ui/hopgui/delegates/HopGuiFileDelegate.java | 6 +
.../hopgui/file/pipeline/HopGuiPipelineGraph.java | 31 +-
.../hopgui/file/workflow/HopGuiWorkflowGraph.java | 30 +-
.../configuration/tabs/ConfigGuiOptionsTab.java | 2 +
.../ui/hopgui/terminal/HopGuiTerminalPanel.java | 698 ++++++++++++++++++++
.../hop/ui/hopgui/terminal/ITerminalWidget.java | 70 ++
.../hop/ui/hopgui/terminal/JediTerminalWidget.java | 451 +++++++++++++
.../ui/hopgui/terminal/TerminalShellDetector.java | 197 ++++++
.../ui/hopgui/messages/messages_en_US.properties | 3 +
ui/src/main/resources/ui/images/terminal.svg | 17 +
.../hopgui/terminal/HopGuiTerminalPanelTest.java | 181 ++++++
.../hopgui/terminal/TerminalShellDetectorTest.java | 76 +++
19 files changed, 2391 insertions(+), 271 deletions(-)
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 2f42f6b05a..a61fccfbf9 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -162,6 +162,12 @@ public class ProjectsGuiPlugin {
//
hopGui.fileDelegate.closeAllFiles();
+ // Clear all terminal tabs (saves current project's terminals first)
+ //
+ if (hopGui.getTerminalPanel() != null) {
+ hopGui.getTerminalPanel().clearAllTerminals();
+ }
+
// This is called only in Hop GUI so we want to start with a new set of
variables
// It avoids variables from one project showing up in another
//
@@ -207,6 +213,12 @@ public class ProjectsGuiPlugin {
//
hopGui.auditDelegate.openLastFiles();
+ // Restore terminal tabs for the new project (per-project terminals)
+ //
+ if (hopGui.getTerminalPanel() != null &&
hopGui.getProps().openLastFile()) {
+ hopGui.getDisplay().asyncExec(() ->
hopGui.getTerminalPanel().restoreTerminals());
+ }
+
// Clear last used, fill it with something useful.
//
IVariables hopGuiVariables = Variables.getADefaultVariableSpace();
diff --git a/ui/pom.xml b/ui/pom.xml
index a11300b27a..6e59c68049 100644
--- a/ui/pom.xml
+++ b/ui/pom.xml
@@ -30,6 +30,8 @@
<name>Hop GUI</name>
<properties>
+ <jediterm.version>3.57</jediterm.version>
+ <pty4j.version>0.13.4</pty4j.version>
<xmlgraphics-commons.version>2.9</xmlgraphics-commons.version>
</properties>
@@ -63,6 +65,34 @@
<version>${org.eclipse.platform.version}</version>
<scope>compile</scope>
</dependency>
+ <!-- JediTerm - JetBrains terminal emulator for POC/testing -->
+ <dependency>
+ <groupId>org.jetbrains.jediterm</groupId>
+ <artifactId>jediterm-core</artifactId>
+ <version>${jediterm.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.jetbrains.jediterm</groupId>
+ <artifactId>jediterm-ui</artifactId>
+ <version>${jediterm.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.jetbrains.pty4j</groupId>
+ <artifactId>pty4j</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
+ <groupId>org.jetbrains.pty4j</groupId>
+ <artifactId>pty4j</artifactId>
+ <version>${pty4j.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.jetbrains.pty4j</groupId>
+ <artifactId>purejavacomm</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-core</artifactId>
@@ -96,4 +126,12 @@
<scope>test</scope>
</dependency>
</dependencies>
+
+ <repositories>
+ <!-- JetBrains repository for JediTerm -->
+ <repository>
+ <id>jetbrains-intellij-dependencies</id>
+
<url>https://packages.jetbrains.team/maven/p/ij/intellij-dependencies</url>
+ </repository>
+ </repositories>
</project>
diff --git a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
index 8755f35c2f..ddd6e4b97e 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
@@ -84,6 +84,7 @@ public class PropsUi extends Props {
"GraphExtraViewVerticalOrientation";
private static final String DISABLE_ZOOM_SCROLLING = "DisableZoomScrolling";
private static final String ENABLE_INFINITE_CANVAS_MOVE =
"EnableInfiniteCanvasMove";
+ private static final String USE_ADVANCED_TERMINAL = "UseAdvancedTerminal";
public static final int DEFAULT_MAX_EXECUTION_LOGGING_TEXT_SIZE = 2000000;
private Map<RGB, RGB> contrastingColors;
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 dbd5930bf7..64964b5fec 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
@@ -249,6 +249,7 @@ public class GuiResource {
@Getter private Image imageStop;
@Getter private Image imageSynonym;
@Getter private Image imageTable;
+ @Getter private Image imageTerminal;
@Getter private Image imageUndo;
@Getter private Image imageUnselectAll;
@Getter private Image imageUp;
@@ -777,6 +778,7 @@ public class GuiResource {
loadAsResource(display, "ui/images/show-selected.svg",
ConstUi.SMALL_ICON_SIZE);
imageSynonym = loadAsResource(display, "ui/images/view.svg",
ConstUi.SMALL_ICON_SIZE);
imageTable = loadAsResource(display, "ui/images/table.svg",
ConstUi.SMALL_ICON_SIZE);
+ imageTerminal = loadAsResource(display, "ui/images/terminal.svg",
ConstUi.SMALL_ICON_SIZE);
imageUser = loadAsResource(display, "ui/images/user.svg",
ConstUi.SMALL_ICON_SIZE);
imageClose = loadAsResource(display, "ui/images/close.svg",
ConstUi.SMALL_ICON_SIZE);
imageDelete = loadAsResource(display, "ui/images/delete.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 af27e96f05..010a2a7d60 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
@@ -30,6 +30,7 @@ import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
+import java.util.Set;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
@@ -204,6 +205,10 @@ public class HopGui
public static final String ID_MAIN_MENU_EDIT_NAV_PREV =
"20400-menu-edit-nav-previous";
public static final String ID_MAIN_MENU_EDIT_NAV_NEXT =
"20410-menu-edit-nav-next";
+ public static final String ID_MAIN_MENU_VIEW_PARENT_ID = "25000-menu-view";
+ public static final String ID_MAIN_MENU_VIEW_TERMINAL =
"25010-menu-view-terminal";
+ public static final String ID_MAIN_MENU_VIEW_NEW_TERMINAL =
"25020-menu-view-new-terminal";
+
public static final String ID_MAIN_MENU_RUN_PARENT_ID = "30000-menu-run";
public static final String ID_MAIN_MENU_RUN_START = "30010-menu-run-execute";
public static final String ID_MAIN_MENU_RUN_PAUSE = "30030-menu-run-pause";
@@ -231,6 +236,16 @@ public class HopGui
public static final String GUI_PLUGIN_PERSPECTIVES_PARENT_ID =
"HopGui-Perspectives";
+ /** Perspective id for the file explorer / data orchestration perspective. */
+ public static final String PERSPECTIVE_ID_EXPLORER = "explorer-perspective";
+
+ /** Id for the execution results toggle button in the sidebar bottom
toolbar. */
+ public static final String SIDEBAR_TOOLBAR_ITEM_EXECUTION_RESULTS =
+ "HopGui-SidebarToolbar-ExecutionResults";
+
+ /** Id for the terminal toggle button in the sidebar bottom toolbar. */
+ public static final String SIDEBAR_TOOLBAR_ITEM_TERMINAL =
"HopGui-SidebarToolbar-Terminal";
+
public static final String DEFAULT_HOP_GUI_NAMESPACE = "hop-gui";
public boolean firstShowing = true;
@@ -268,11 +283,18 @@ public class HopGui
private GuiToolbarWidgets statusToolbarWidgets;
private Composite perspectivesSidebar;
+ private ToolBar bottomToolbar;
+ private final java.util.List<SidebarToolbarItemDescriptor>
sidebarToolbarDescriptors =
+ new java.util.ArrayList<>();
private java.util.List<SidebarButton> sidebarButtons = new
java.util.ArrayList<>();
private Composite mainPerspectivesComposite;
private HopPerspectiveManager perspectiveManager;
private IHopPerspective activePerspective;
- private ToolItem explorerPerspectiveToolItem;
+ private org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel terminalPanel;
+
+ public org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel
getTerminalPanel() {
+ return terminalPanel;
+ }
private static final PrintStream originalSystemOut = System.out;
private static final PrintStream originalSystemErr = System.err;
@@ -483,6 +505,8 @@ public class HopGui
auditDelegate.openLastFiles();
}
+ // Terminal restoration is handled by the Projects plugin
+
// We need to start tracking file history again.
//
reOpeningFiles = false;
@@ -622,7 +646,10 @@ public class HopGui
// Create styled sidebar button with hover, selection, and rounded
corners
// This works for both desktop SWT and web/RAP modes
- createStyledSidebarButton(perspectivesSidebar, image, tooltip,
perspective);
+ // Get the perspectives container from the sidebar
+ Composite perspectivesContainer =
+ (Composite) perspectivesSidebar.getData("perspectivesContainer");
+ createStyledSidebarButton(perspectivesContainer, image, tooltip,
perspective);
}
perspectivesSidebar.layout(true, true);
@@ -659,6 +686,229 @@ public class HopGui
return item;
}
+ /**
+ * Create a styled sidebar button with modern appearance. Features rounded
corners, hover effects,
+ * and selection colors.
+ */
+ private void createStyledSidebarButton(
+ Composite parent, Image image, String tooltip, IHopPerspective
perspective) {
+
+ SidebarButton button = new SidebarButton(parent, image, tooltip,
perspective);
+ sidebarButtons.add(button);
+
+ GridData gd = new GridData();
+ gd.widthHint = (int) (34 * PropsUi.getNativeZoomFactor());
+ gd.heightHint = (int) (34 * PropsUi.getNativeZoomFactor());
+ button.composite.setLayoutData(gd);
+ }
+
+ /** Custom sidebar button class with hover, selection, and rounded corners */
+ private class SidebarButton {
+ Control composite; // Use Control to allow both Composite (RAP) and Canvas
(desktop)
+ Image image;
+ IHopPerspective perspective;
+ boolean isHovered = false;
+ boolean isSelected = false;
+
+ Color selectionBg = GuiResource.getInstance().getColorLightBlue();
+ Color hoverBg = GuiResource.getInstance().getColorGray();
+ Color normalBg = GuiResource.getInstance().getWidgetBackGroundColor();
+
+ public SidebarButton(
+ Composite parent, Image image, String tooltip, IHopPerspective
perspective) {
+ this.image = image;
+ this.perspective = perspective;
+
+ // Create a label inside the composite to display the image (only for
RAP)
+ final Label imageLabel;
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ // For RAP/web: use Composite with Label child
+ Composite comp = new Composite(parent, SWT.NONE);
+ composite = comp;
+ comp.setToolTipText(tooltip);
+ comp.setBackground(normalBg);
+
+ // Set custom variant for CSS styling in RAP
+ comp.setData("org.eclipse.rap.rwt.customVariant", "sidebarButton");
+
+ // Use GridLayout to center the image without stretching
+ GridLayout layout = new GridLayout(1, false);
+ layout.marginWidth = 0;
+ layout.marginHeight = 0;
+ layout.horizontalSpacing = 0;
+ layout.verticalSpacing = 0;
+ comp.setLayout(layout);
+
+ imageLabel = new Label(comp, SWT.NONE);
+ imageLabel.setImage(image);
+ imageLabel.setBackground(normalBg);
+ imageLabel.setToolTipText(tooltip);
+ imageLabel.setData("org.eclipse.rap.rwt.customVariant",
"sidebarButton");
+
+ // Center the label in the composite
+ GridData gd = new GridData(SWT.CENTER, SWT.CENTER, true, true);
+ imageLabel.setLayoutData(gd);
+ } else {
+ Canvas canvas = new Canvas(parent, SWT.NONE);
+ composite = canvas;
+ canvas.setToolTipText(tooltip);
+ canvas.setBackground(normalBg);
+ imageLabel = null;
+ }
+
+ // Update background colors method for both RAP and desktop
+ final Runnable updateColors =
+ () -> {
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ // For RAP, update composite background color (rounded corners
applied via CSS)
+ Color bgColor;
+ if (isSelected) {
+ bgColor = selectionBg;
+ } else if (isHovered) {
+ bgColor = hoverBg;
+ } else {
+ bgColor = normalBg;
+ }
+ composite.setBackground(bgColor);
+ // Keep label background transparent/matching to show composite
background
+ if (imageLabel != null) {
+ imageLabel.setBackground(bgColor);
+ }
+ // Force redraw in RAP
+ composite.redraw();
+ if (composite instanceof Composite) {
+ ((Composite) composite).layout();
+ }
+ } else {
+ // For desktop Canvas, trigger repaint
+ composite.redraw();
+ }
+ };
+
+ // Only add paint listener for desktop SWT (RAP doesn't support it)
+ if (!EnvironmentUtils.getInstance().isWeb()) {
+ composite.addPaintListener(
+ e -> {
+ GC gc = e.gc;
+ Point size = composite.getSize();
+
+ gc.setAntialias(SWT.ON);
+
+ // Choose background color
+ if (isSelected) {
+ gc.setBackground(selectionBg);
+ } else if (isHovered) {
+ gc.setBackground(hoverBg);
+ } else {
+ gc.setBackground(normalBg);
+ }
+
+ // Fill rounded rectangle
+ gc.fillRoundRectangle(4, 4, size.x - 8, size.y - 8, 8, 8);
+
+ // Draw image centered
+ if (image != null && !image.isDisposed()) {
+ Rectangle imgBounds = image.getBounds();
+ int x = (size.x - imgBounds.width) / 2;
+ int y = (size.y - imgBounds.height) / 2;
+ gc.drawImage(image, x, y);
+ }
+ });
+ }
+
+ // Mouse listeners
+ composite.addListener(
+ SWT.MouseEnter,
+ e -> {
+ isHovered = true;
+ updateColors.run();
+ });
+
+ composite.addListener(
+ SWT.MouseExit,
+ e -> {
+ isHovered = false;
+ updateColors.run();
+ });
+
+ composite.addListener(
+ SWT.MouseDown,
+ e -> {
+ // Deselect all other buttons
+ for (SidebarButton btn : sidebarButtons) {
+ btn.setSelected(false);
+ }
+
+ // Select this button
+ setSelected(true);
+
+ // Handle perspective activation
+ if (perspective instanceof ExplorerPerspective explorerPerspective
+ && mainPerspectivesComposite != null
+ && !mainPerspectivesComposite.isDisposed()) {
+ StackLayout layout = (StackLayout)
mainPerspectivesComposite.getLayout();
+ if (layout.topControl == explorerPerspective.getControl()) {
+ explorerPerspective.toggleFileExplorerPanel();
+ return;
+ }
+ }
+ setActivePerspective(perspective);
+ });
+
+ // Also attach listeners to the image label for better hit detection
(RAP only)
+ if (imageLabel != null) {
+ imageLabel.addListener(
+ SWT.MouseEnter,
+ e -> {
+ isHovered = true;
+ updateColors.run();
+ });
+ imageLabel.addListener(
+ SWT.MouseExit,
+ e -> {
+ isHovered = false;
+ updateColors.run();
+ });
+ imageLabel.addListener(
+ SWT.MouseDown,
+ e -> {
+ // Deselect all other buttons
+ for (SidebarButton btn : sidebarButtons) {
+ btn.setSelected(false);
+ }
+
+ // Select this button
+ setSelected(true);
+
+ // Handle perspective activation
+ if (perspective instanceof ExplorerPerspective
explorerPerspective
+ && mainPerspectivesComposite != null
+ && !mainPerspectivesComposite.isDisposed()) {
+ StackLayout layout = (StackLayout)
mainPerspectivesComposite.getLayout();
+ if (layout.topControl == explorerPerspective.getControl()) {
+ explorerPerspective.toggleFileExplorerPanel();
+ return;
+ }
+ }
+ setActivePerspective(perspective);
+ });
+ }
+
+ // Store the update method for later use
+ composite.setData("updateColors", updateColors);
+ }
+
+ public void setSelected(boolean selected) {
+ this.isSelected = selected;
+ if (!composite.isDisposed()) {
+ Runnable updateColors = (Runnable) composite.getData("updateColors");
+ if (updateColors != null) {
+ updateColors.run();
+ }
+ }
+ }
+ }
+
private static Display setupDisplay() {
// Bootstrap Hop
//
@@ -955,6 +1205,14 @@ public class HopGui
@GuiKeyboardShortcut(control = true, key = 'c')
@GuiOsxKeyboardShortcut(command = true, key = 'c')
public void menuEditCopySelected() {
+ Control focusControl = display.getFocusControl();
+ if (focusControl instanceof org.eclipse.swt.custom.StyledText styledText) {
+ if (styledText.getSelectionCount() > 0) {
+ return;
+ }
+ }
+
+ // Otherwise, delegate to the active file type handler (pipeline/workflow)
getActiveFileTypeHandler().copySelectedToClipboard();
}
@@ -967,6 +1225,13 @@ public class HopGui
@GuiKeyboardShortcut(control = true, key = 'v')
@GuiOsxKeyboardShortcut(command = true, key = 'v')
public void menuEditPaste() {
+ Control focusControl = display.getFocusControl();
+ if (focusControl instanceof org.eclipse.swt.custom.StyledText) {
+ // Terminal handles paste internally
+ return;
+ }
+
+ // Otherwise, delegate to the active file type handler (pipeline/workflow)
getActiveFileTypeHandler().pasteFromClipboard();
}
@@ -1131,6 +1396,51 @@ public class HopGui
getActivePerspective().navigateToNextFile();
}
+ // ======================== View Menu ========================
+
+ @GuiMenuElement(
+ root = ID_MAIN_MENU,
+ id = ID_MAIN_MENU_VIEW_PARENT_ID,
+ label = "i18n::HopGui.Menu.View",
+ parentId = ID_MAIN_MENU)
+ public void menuView() {
+ // Nothing is done here.
+ }
+
+ @GuiMenuElement(
+ root = ID_MAIN_MENU,
+ id = ID_MAIN_MENU_VIEW_TERMINAL,
+ label = "i18n::HopGui.Menu.View.Terminal",
+ parentId = ID_MAIN_MENU_VIEW_PARENT_ID)
+ @GuiKeyboardShortcut(control = true, key = '`')
+ @GuiOsxKeyboardShortcut(control = true, key = '`')
+ public void menuViewTerminal() {
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ return;
+ }
+ if (terminalPanel != null) {
+ terminalPanel.toggleTerminal();
+ }
+ }
+
+ @GuiMenuElement(
+ root = ID_MAIN_MENU,
+ id = ID_MAIN_MENU_VIEW_NEW_TERMINAL,
+ label = "i18n::HopGui.Menu.View.NewTerminal",
+ parentId = ID_MAIN_MENU_VIEW_PARENT_ID)
+ @GuiKeyboardShortcut(control = true, shift = true, key = '`')
+ @GuiOsxKeyboardShortcut(control = true, shift = true, key = '`')
+ public void menuViewNewTerminal() {
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ return;
+ }
+ if (terminalPanel != null) {
+ terminalPanel.createNewTerminal(null, null);
+ }
+ }
+
+ // ======================== Run Menu ========================
+
@GuiMenuElement(
root = ID_MAIN_MENU,
id = ID_MAIN_MENU_RUN_PARENT_ID,
@@ -1292,14 +1602,67 @@ public class HopGui
// Create custom sidebar composite instead of ToolBar for better control
perspectivesSidebar = new Composite(mainHopGuiComposite, SWT.NONE);
PropsUi.setLook(perspectivesSidebar);
+ perspectivesSidebar.setLayout(new FormLayout());
- // Use GridLayout for vertical stacking
- org.eclipse.swt.layout.GridLayout sidebarLayout =
+ // Container for perspective buttons (uses GridLayout for stacking)
+ Composite perspectivesContainer = new Composite(perspectivesSidebar,
SWT.NONE);
+ org.eclipse.swt.layout.GridLayout perspectivesLayout =
new org.eclipse.swt.layout.GridLayout(1, false);
- sidebarLayout.marginWidth = 1;
- sidebarLayout.marginHeight = 2;
- sidebarLayout.verticalSpacing = 1; // Minimal spacing between buttons
- perspectivesSidebar.setLayout(sidebarLayout);
+ perspectivesLayout.marginWidth = 1;
+ perspectivesLayout.marginHeight = 2;
+ perspectivesLayout.verticalSpacing = 1; // Minimal spacing between buttons
+ perspectivesContainer.setLayout(perspectivesLayout);
+ FormData fdPerspectivesContainer = new FormData();
+ fdPerspectivesContainer.left = new FormAttachment(0, 0);
+ fdPerspectivesContainer.top = new FormAttachment(0, 0);
+ fdPerspectivesContainer.right = new FormAttachment(100, 0);
+ perspectivesContainer.setLayoutData(fdPerspectivesContainer);
+
+ bottomToolbar = new ToolBar(perspectivesSidebar, SWT.WRAP | SWT.RIGHT |
SWT.VERTICAL);
+ PropsUi.setLook(bottomToolbar, Props.WIDGET_STYLE_TOOLBAR);
+ FormData fdBottomToolbar = new FormData();
+ fdBottomToolbar.left = new FormAttachment(0, 0);
+ fdBottomToolbar.right = new FormAttachment(100, 0);
+ // Add small margin at bottom to create visual separation from
project/environment dropdowns
+ fdBottomToolbar.bottom = new FormAttachment(100, -4);
+ bottomToolbar.setLayoutData(fdBottomToolbar);
+
+ // Register built-in sidebar toolbar items (visibility depends on active
perspective).
+ // File explorer: both terminal and execution. Other perspectives:
terminal only.
+ // List order: terminal then execution; refresh draws in reverse so
execution appears above.
+ int sidebarIconSize = 21;
+ sidebarToolbarDescriptors.add(
+ SidebarToolbarItemDescriptor.builder()
+ .id(SIDEBAR_TOOLBAR_ITEM_TERMINAL)
+ .imagePath("ui/images/terminal.svg")
+ .imageSize(sidebarIconSize)
+ .tooltip("Toggle Terminal Panel")
+ .onSelect(
+ () -> {
+ if (terminalPanel != null) {
+ terminalPanel.toggleTerminal();
+ }
+ })
+ .available(!EnvironmentUtils.getInstance().isWeb())
+ .build());
+ sidebarToolbarDescriptors.add(
+ SidebarToolbarItemDescriptor.builder()
+ .id(SIDEBAR_TOOLBAR_ITEM_EXECUTION_RESULTS)
+ .visibleForPerspectiveIds(Set.of(PERSPECTIVE_ID_EXPLORER))
+ .imagePath("ui/images/show-results.svg")
+ .imageSize(sidebarIconSize)
+ .tooltip("Toggle Execution Results (Logging/Metrics/Problems)")
+ .onSelect(this::toggleExecutionResults)
+ .available(true)
+ .build());
+
+ refreshBottomToolbarItems();
+
+ // Anchor perspectives container above bottom toolbar
+ fdPerspectivesContainer.bottom = new FormAttachment(bottomToolbar, 0);
+
+ // Store perspectivesContainer for use in loadPerspectives
+ perspectivesSidebar.setData("perspectivesContainer",
perspectivesContainer);
FormData fdSidebar = new FormData();
fdSidebar.left = new FormAttachment(0, 0);
@@ -1311,17 +1674,34 @@ public class HopGui
/**
* Add a main composite where the various perspectives can parent on to show
stuff... Its area is
- * to just below the main toolbar and to the right of the perspectives
toolbar
+ * to just below the main toolbar and to the right of the perspectives
toolbar.
+ *
+ * <p>Wraps everything in a HopGuiTerminalPanel which provides the terminal
panel at the bottom,
+ * with perspectives rendering in the top section.
*/
private void addMainPerspectivesComposite() {
- mainPerspectivesComposite = new Composite(mainHopGuiComposite,
SWT.NO_BACKGROUND);
- mainPerspectivesComposite.setLayout(new StackLayout());
- FormData fdMain = new FormData();
- fdMain.top = new FormAttachment(0, 0);
- fdMain.left = new FormAttachment(perspectivesSidebar, 0);
- fdMain.bottom = new FormAttachment(100, 0);
- fdMain.right = new FormAttachment(100, 0);
- mainPerspectivesComposite.setLayoutData(fdMain);
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ mainPerspectivesComposite = new Composite(mainHopGuiComposite, SWT.NONE);
+ FormData fdPerspectives = new FormData();
+ fdPerspectives.top = new FormAttachment(0, 0);
+ fdPerspectives.left = new FormAttachment(perspectivesSidebar, 0);
+ fdPerspectives.bottom = new FormAttachment(100, 0);
+ fdPerspectives.right = new FormAttachment(100, 0);
+ mainPerspectivesComposite.setLayoutData(fdPerspectives);
+ mainPerspectivesComposite.setLayout(new StackLayout());
+ } else {
+ terminalPanel =
+ new
org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel(mainHopGuiComposite,
this);
+ FormData fdTerminalPanel = new FormData();
+ fdTerminalPanel.top = new FormAttachment(0, 0);
+ fdTerminalPanel.left = new FormAttachment(perspectivesSidebar, 0);
+ fdTerminalPanel.bottom = new FormAttachment(100, 0);
+ fdTerminalPanel.right = new FormAttachment(100, 0);
+ terminalPanel.setLayoutData(fdTerminalPanel);
+
+ mainPerspectivesComposite = terminalPanel.getPerspectiveComposite();
+ mainPerspectivesComposite.setLayout(new StackLayout());
+ }
}
public void setUndoMenu(IUndo undoInterface) {
@@ -1473,6 +1853,11 @@ public class HopGui
if (control == null || control.isDisposed()) {
return;
}
+
+ if (control.getData("HOP_TERMINAL_WIDGET") == Boolean.TRUE) {
+ return;
+ }
+
control.removeKeyListener(keyHandler);
control.addKeyListener(keyHandler);
@@ -1532,249 +1917,97 @@ public class HopGui
layout.topControl = perspective.getControl();
mainPerspectivesComposite.layout();
- // Selection is handled by updateSidebarButtonSelection()
-
- // Update sidebar button selection states
- updateSidebarButtonSelection(perspective);
-
// Notify the perspective that it has been activated.
//
perspective.perspectiveActivated();
perspectiveManager.notifyPerspectiveActivated(perspective);
- }
- public boolean isActivePerspective(IHopPerspective perspective) {
- return activePerspective != null && activePerspective.equals(perspective);
+ refreshBottomToolbarItems();
}
- /** Update the visual selection state of sidebar buttons when perspective
changes. */
- private void updateSidebarButtonSelection(IHopPerspective activePerspective)
{
- for (SidebarButton button : sidebarButtons) {
- button.setSelected(button.perspective.equals(activePerspective));
+ /**
+ * Register an item for the bottom-left sidebar toolbar. Visibility is
determined by the active
+ * perspective (see {@link SidebarToolbarItemDescriptor}). If the toolbar
already exists, it is
+ * refreshed immediately.
+ */
+ public void addSidebarToolbarItem(SidebarToolbarItemDescriptor descriptor) {
+ if (descriptor != null && !sidebarToolbarDescriptors.contains(descriptor))
{
+ sidebarToolbarDescriptors.add(descriptor);
+ if (bottomToolbar != null && !bottomToolbar.isDisposed()) {
+ refreshBottomToolbarItems();
+ }
}
}
/**
- * Create a styled sidebar button with modern appearance. Features rounded
corners, hover effects,
- * and selection colors.
+ * Refresh the bottom sidebar toolbar so only items visible for the current
perspective are shown.
+ * Items are added in reverse descriptor order so that the second, third,
etc. buttons appear
+ * above the first (SWT vertical toolbar lays out first-added at top). This
avoids overlapping and
+ * keeps perspective-specific buttons (e.g. execution) above the
always-visible ones (e.g.
+ * terminal).
*/
- private void createStyledSidebarButton(
- Composite parent, Image image, String tooltip, IHopPerspective
perspective) {
-
- SidebarButton button = new SidebarButton(parent, image, tooltip,
perspective);
- sidebarButtons.add(button);
-
- org.eclipse.swt.layout.GridData gd = new org.eclipse.swt.layout.GridData();
- gd.widthHint = (int) (34 * PropsUi.getNativeZoomFactor());
- gd.heightHint = (int) (34 * PropsUi.getNativeZoomFactor());
- button.composite.setLayoutData(gd);
- }
-
- /** Custom sidebar button class with hover, selection, and rounded corners */
- private class SidebarButton {
- Control composite; // Use Control to allow both Composite (RAP) and Canvas
(desktop)
- Image image;
- IHopPerspective perspective;
- boolean isHovered = false;
- boolean isSelected = false;
-
- Color selectionBg = GuiResource.getInstance().getColorLightBlue();
- Color hoverBg = GuiResource.getInstance().getColorGray();
- Color normalBg = GuiResource.getInstance().getWidgetBackGroundColor();
-
- public SidebarButton(
- Composite parent, Image image, String tooltip, IHopPerspective
perspective) {
- this.image = image;
- this.perspective = perspective;
-
- // Create a label inside the composite to display the image (only for
RAP)
- final Label imageLabel;
- if (EnvironmentUtils.getInstance().isWeb()) {
- // For RAP/web: use Composite with Label child
- Composite comp = new Composite(parent, SWT.NONE);
- composite = comp;
- comp.setToolTipText(tooltip);
- comp.setBackground(normalBg);
-
- // Set custom variant for CSS styling in RAP
- comp.setData("org.eclipse.rap.rwt.customVariant", "sidebarButton");
-
- // Use GridLayout to center the image without stretching
- GridLayout layout = new GridLayout(1, false);
- layout.marginWidth = 0;
- layout.marginHeight = 0;
- layout.horizontalSpacing = 0;
- layout.verticalSpacing = 0;
- comp.setLayout(layout);
-
- imageLabel = new Label(comp, SWT.NONE);
- imageLabel.setImage(image);
- imageLabel.setBackground(normalBg);
- imageLabel.setToolTipText(tooltip);
- imageLabel.setData("org.eclipse.rap.rwt.customVariant",
"sidebarButton");
+ public void refreshBottomToolbarItems() {
+ if (bottomToolbar == null || bottomToolbar.isDisposed()) {
+ return;
+ }
+ String activePerspectiveId = activePerspective != null ?
activePerspective.getId() : "";
- // Center the label in the composite
- GridData gd = new GridData(SWT.CENTER, SWT.CENTER, true, true);
- imageLabel.setLayoutData(gd);
+ // Collect visible descriptors, then add in reverse order so extra buttons
go on top
+ List<SidebarToolbarItemDescriptor> visible = new ArrayList<>();
+ for (SidebarToolbarItemDescriptor d : sidebarToolbarDescriptors) {
+ if (!d.isAvailable()) {
+ continue;
+ }
+ boolean show;
+ if (!d.getVisibleForPerspectiveIds().isEmpty()) {
+ show = d.getVisibleForPerspectiveIds().contains(activePerspectiveId);
+ } else if (d.getHiddenForPerspectiveIds().isEmpty()) {
+ show = true;
} else {
- Canvas canvas = new Canvas(parent, SWT.NONE);
- composite = canvas;
- canvas.setToolTipText(tooltip);
- canvas.setBackground(normalBg);
- imageLabel = null;
+ show = !d.getHiddenForPerspectiveIds().contains(activePerspectiveId);
}
-
- // Update background colors method for both RAP and desktop
- final Runnable updateColors =
- () -> {
- if (EnvironmentUtils.getInstance().isWeb()) {
- // For RAP, update composite background color (rounded corners
applied via CSS)
- Color bgColor;
- if (isSelected) {
- bgColor = selectionBg;
- } else if (isHovered) {
- bgColor = hoverBg;
- } else {
- bgColor = normalBg;
- }
- composite.setBackground(bgColor);
- // Keep label background transparent/matching to show composite
background
- if (imageLabel != null) {
- imageLabel.setBackground(bgColor);
- }
- // Force redraw in RAP
- composite.redraw();
- if (composite instanceof Composite) {
- ((Composite) composite).layout();
- }
- } else {
- // For desktop Canvas, trigger repaint
- composite.redraw();
- }
- };
-
- // Only add paint listener for desktop SWT (RAP doesn't support it)
- if (!EnvironmentUtils.getInstance().isWeb()) {
- composite.addPaintListener(
- e -> {
- GC gc = e.gc;
- Point size = composite.getSize();
-
- gc.setAntialias(SWT.ON);
-
- // Choose background color
- if (isSelected) {
- gc.setBackground(selectionBg);
- } else if (isHovered) {
- gc.setBackground(hoverBg);
- } else {
- gc.setBackground(normalBg);
- }
-
- // Fill rounded rectangle
- gc.fillRoundRectangle(4, 4, size.x - 8, size.y - 8, 8, 8);
-
- // Draw image centered
- if (image != null && !image.isDisposed()) {
- Rectangle imgBounds = image.getBounds();
- int x = (size.x - imgBounds.width) / 2;
- int y = (size.y - imgBounds.height) / 2;
- gc.drawImage(image, x, y);
- }
- });
+ if (show) {
+ visible.add(d);
}
+ }
- // Mouse listeners
- composite.addListener(
- SWT.MouseEnter,
- e -> {
- isHovered = true;
- updateColors.run();
- });
-
- composite.addListener(
- SWT.MouseExit,
- e -> {
- isHovered = false;
- updateColors.run();
- });
-
- composite.addListener(
- SWT.MouseDown,
- e -> {
- // Deselect all other buttons
- for (SidebarButton btn : sidebarButtons) {
- btn.setSelected(false);
- }
-
- // Select this button
- setSelected(true);
+ // Dispose existing items
+ for (ToolItem item : bottomToolbar.getItems()) {
+ item.dispose();
+ }
- // Handle perspective activation
- if (perspective instanceof ExplorerPerspective explorerPerspective
- && mainPerspectivesComposite != null
- && !mainPerspectivesComposite.isDisposed()) {
- StackLayout layout = (StackLayout)
mainPerspectivesComposite.getLayout();
- if (layout.topControl == explorerPerspective.getControl()) {
- explorerPerspective.toggleFileExplorerPanel();
- return;
- }
+ // Add in reverse order: last in list becomes first (top) in toolbar so we
don't overlap
+ for (int i = visible.size() - 1; i >= 0; i--) {
+ SidebarToolbarItemDescriptor d = visible.get(i);
+ ToolItem item = new ToolItem(bottomToolbar, SWT.PUSH);
+ Image img =
+ GuiResource.getInstance().getImage(d.getImagePath(),
d.getImageSize(), d.getImageSize());
+ item.setImage(img);
+ item.setToolTipText(d.getTooltip());
+ item.setData("descriptor", d);
+ item.addListener(
+ SWT.Selection,
+ event -> {
+ SidebarToolbarItemDescriptor desc =
+ (SidebarToolbarItemDescriptor) item.getData("descriptor");
+ if (desc != null && desc.getOnSelect() != null) {
+ desc.getOnSelect().run();
}
- setActivePerspective(perspective);
});
-
- // Also attach listeners to the image label for better hit detection
(RAP only)
- if (imageLabel != null) {
- imageLabel.addListener(
- SWT.MouseEnter,
- e -> {
- isHovered = true;
- updateColors.run();
- });
- imageLabel.addListener(
- SWT.MouseExit,
- e -> {
- isHovered = false;
- updateColors.run();
- });
- imageLabel.addListener(
- SWT.MouseDown,
- e -> {
- // Deselect all other buttons
- for (SidebarButton btn : sidebarButtons) {
- btn.setSelected(false);
- }
-
- // Select this button
- setSelected(true);
-
- // Handle perspective activation
- if (perspective instanceof ExplorerPerspective
explorerPerspective
- && mainPerspectivesComposite != null
- && !mainPerspectivesComposite.isDisposed()) {
- StackLayout layout = (StackLayout)
mainPerspectivesComposite.getLayout();
- if (layout.topControl == explorerPerspective.getControl()) {
- explorerPerspective.toggleFileExplorerPanel();
- return;
- }
- }
- setActivePerspective(perspective);
- });
- }
-
- // Store the update method for later use
- composite.setData("updateColors", updateColors);
}
+ bottomToolbar.pack();
+ bottomToolbar.getParent().layout(true, true);
+ }
- public void setSelected(boolean selected) {
- this.isSelected = selected;
- if (!composite.isDisposed()) {
- Runnable updateColors = (Runnable) composite.getData("updateColors");
- if (updateColors != null) {
- updateColors.run();
- }
- }
+ public boolean isActivePerspective(IHopPerspective perspective) {
+ return activePerspective != null && activePerspective.equals(perspective);
+ }
+
+ /** Update the visual selection state of sidebar buttons when perspective
changes. */
+ private void updateSidebarButtonSelection(IHopPerspective activePerspective)
{
+ for (SidebarButton button : sidebarButtons) {
+ button.setSelected(button.perspective.equals(activePerspective));
}
}
@@ -1863,6 +2096,20 @@ public class HopGui
return null;
}
+ /** Toggle execution results panel for the currently active pipeline or
workflow */
+ public void toggleExecutionResults() {
+ HopGuiPipelineGraph pipelineGraph = getActivePipelineGraph();
+ if (pipelineGraph != null) {
+ pipelineGraph.showExecutionResults();
+ return;
+ }
+
+ HopGuiWorkflowGraph workflowGraph = getActiveWorkflowGraph();
+ if (workflowGraph != null) {
+ workflowGraph.showExecutionResults();
+ }
+ }
+
public static MetadataPerspective getMetadataPerspective() {
return
HopGui.getInstance().getPerspectiveManager().findPerspective(MetadataPerspective.class);
}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
index c202228f1e..e3bfa0fac8 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
@@ -68,15 +68,22 @@ public class HopGuiKeyHandler extends KeyAdapter {
// TODO: allow for keyboard shortcut priorities for certain objects.
//
- // Ignore shortcuts inside Text or Combo widgets
+ // If the event has already been handled by another listener (e.g.
terminal), skip it
+ if (!event.doit) {
+ return;
+ }
+
+ // Ignore shortcuts inside Text, Combo, or StyledText widgets (including
terminal)
if (event.widget instanceof Text
|| event.widget instanceof Combo
- || event.widget instanceof CCombo) {
- // Ignore Copy/Cut/Paste/Select all
- String keys = new String(new char[] {'a', 'c', 'v', 'x'});
- if ((event.stateMask & (SWT.CONTROL + SWT.COMMAND)) != 0
- && keys.indexOf(event.keyCode) >= 0) {
- return;
+ || event.widget instanceof CCombo
+ || event.widget instanceof org.eclipse.swt.custom.StyledText) {
+ // Ignore Copy/Cut/Paste/Select all - check both keyCode and character
+ if ((event.stateMask & (SWT.CONTROL + SWT.COMMAND)) != 0) {
+ char key = Character.toLowerCase((char) event.keyCode);
+ if (key == 'a' || key == 'c' || key == 'v' || key == 'x') {
+ return;
+ }
}
// Ignore DEL and Backspace
if (event.keyCode == SWT.DEL || event.character == SWT.BS) {
@@ -108,6 +115,11 @@ public class HopGuiKeyHandler extends KeyAdapter {
if (!control.isVisible()) {
return false;
}
+ // Also skip if the event widget (focused widget) is NOT within this
control's hierarchy
+ // This prevents pipeline/workflow shortcuts from firing when focus is
in the terminal
+ if (!isWidgetInControlHierarchy(event.widget, control)) {
+ return false;
+ }
} catch (SWTException e) {
// Invalid thread: none of our business, bail out
//
@@ -166,4 +178,30 @@ public class HopGuiKeyHandler extends KeyAdapter {
}
return false;
}
+
+ /**
+ * Check if a widget is within the hierarchy of a control
+ *
+ * @param widget The widget to check (typically the focused widget)
+ * @param control The parent control to check against
+ * @return true if widget is within control's hierarchy, false otherwise
+ */
+ private boolean isWidgetInControlHierarchy(Object widget, Control control) {
+ if (!(widget instanceof Control)) {
+ return false;
+ }
+
+ Control current = (Control) widget;
+ while (current != null) {
+ if (current == control) {
+ return true;
+ }
+ try {
+ current = current.getParent();
+ } catch (Exception e) {
+ return false;
+ }
+ }
+ return false;
+ }
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/SidebarToolbarItemDescriptor.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/SidebarToolbarItemDescriptor.java
new file mode 100644
index 0000000000..9815ae8ac4
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/SidebarToolbarItemDescriptor.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import java.util.Collections;
+import java.util.Set;
+import lombok.Builder;
+import lombok.Value;
+
+/**
+ * Descriptor for a button in the bottom-left sidebar toolbar (below the
perspective buttons).
+ * Visibility is driven by the active perspective so any combination of
buttons can be shown or
+ * hidden per perspective.
+ *
+ * <p>Visibility rules (evaluated in order):
+ *
+ * <ul>
+ * <li>If {@link #visibleForPerspectiveIds} is non-empty: button is visible
only when the active
+ * perspective id is in that set (e.g. only in file explorer).
+ * <li>Else: button is visible in all perspectives except those in {@link
+ * #hiddenForPerspectiveIds} (empty = visible everywhere, e.g. terminal).
+ * </ul>
+ *
+ * This allows e.g. "only in explorer", "always", or "everywhere except X".
+ */
+@Value
+@Builder
+public class SidebarToolbarItemDescriptor {
+
+ /** Unique id for this item (e.g. for extensions to replace or identify). */
+ String id;
+
+ /**
+ * When non-empty: button is visible only when the active perspective id is
in this set. When
+ * empty: visibility is "all perspectives except {@link
#hiddenForPerspectiveIds}".
+ */
+ @Builder.Default Set<String> visibleForPerspectiveIds =
Collections.emptySet();
+
+ /**
+ * Only used when {@link #visibleForPerspectiveIds} is empty. Perspective
ids in this set hide the
+ * button; empty = visible in all perspectives.
+ */
+ @Builder.Default Set<String> hiddenForPerspectiveIds =
Collections.emptySet();
+
+ /** Image path (e.g. "ui/images/show-results.svg"). */
+ String imagePath;
+
+ /** Icon size in pixels. */
+ int imageSize;
+
+ /** Tooltip text. */
+ String tooltip;
+
+ /** Called when the button is selected. */
+ Runnable onSelect;
+
+ /** Whether this item is available (e.g. terminal only when not in web). */
+ @Builder.Default boolean available = true;
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
index 94dd7ff987..f4ea9e5e79 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
@@ -318,6 +318,12 @@ public class HopGuiFileDelegate {
//
hopGui.auditDelegate.writeLastOpenFiles();
+ // Save all open terminal tabs
+ //
+ if (hopGui.getTerminalPanel() != null) {
+ hopGui.getTerminalPanel().saveTerminalsOnShutdown();
+ }
+
return true;
}
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 3cfeed8cef..70d83168aa 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
@@ -3575,6 +3575,8 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
public void drawPipelineImage(GC swtGc, int width, int height) {
+ if (EnvironmentUtils.getInstance().isWeb()) {}
+
IGc gc = new SwtGc(swtGc, width, height, iconSize);
try {
PropsUi propsUi = PropsUi.getInstance();
@@ -4206,7 +4208,7 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
/** If the extra tab view at the bottom is empty, we close it. */
public void checkEmptyExtraView() {
- if (extraViewTabFolder.getItemCount() == 0) {
+ if (extraViewTabFolder != null && extraViewTabFolder.getItemCount() == 0) {
disposeExtraView();
}
}
@@ -4217,6 +4219,8 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
}
extraViewTabFolder.dispose();
+ extraViewTabFolder = null;
+
sashForm.layout();
sashForm.setWeights(100);
@@ -4232,15 +4236,13 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
//
boolean maximized = sashForm.getMaximizedControl() != null;
if (maximized) {
- // Minimize
- //
+ // Restore
sashForm.setMaximizedControl(null);
minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
minMaxItem.setToolTipText(
BaseMessages.getString(PKG,
"PipelineGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
} else {
// Maximize
- //
sashForm.setMaximizedControl(extraViewTabFolder);
minMaxItem.setImage(GuiResource.getInstance().getImageMinimizePanel());
minMaxItem.setToolTipText(
@@ -4268,11 +4270,19 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
/** Add an extra view to the main composite SashForm */
public void addExtraView() {
- // Add a tab folder ...
- //
+ // 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);
PropsUi.setLook(extraViewTabFolder, Props.WIDGET_STYLE_TAB);
+ // Layout the tab folder to fill its parent
+ FormData fdTabFolder = new FormData();
+ fdTabFolder.left = new FormAttachment(0, 0);
+ fdTabFolder.right = new FormAttachment(100, 0);
+ fdTabFolder.top = new FormAttachment(0, 0);
+ fdTabFolder.bottom = new FormAttachment(100, 0);
+ extraViewTabFolder.setLayoutData(fdTabFolder);
+
extraViewTabFolder.addMouseListener(
new MouseAdapter() {
@@ -4286,13 +4296,6 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
}
});
- FormData fdTabFolder = new FormData();
- fdTabFolder.left = new FormAttachment(0, 0);
- fdTabFolder.right = new FormAttachment(100, 0);
- fdTabFolder.top = new FormAttachment(0, 0);
- fdTabFolder.bottom = new FormAttachment(100, 0);
- extraViewTabFolder.setLayoutData(fdTabFolder);
-
// Create toolbar for close and min/max to the upper right corner...
//
ToolBar extraViewToolBar = new ToolBar(extraViewTabFolder, SWT.FLAT);
@@ -4323,6 +4326,8 @@ 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);
}
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 2ec438a9ac..6bdf21a40b 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
@@ -3615,11 +3615,19 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
/** Add an extra view to the main composite SashForm */
public void addExtraView() {
- // Add a tab folder ...
- //
+ // 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);
PropsUi.setLook(extraViewTabFolder, Props.WIDGET_STYLE_TAB);
+ // Layout the tab folder to fill its parent
+ FormData fdTabFolder = new FormData();
+ fdTabFolder.left = new FormAttachment(0, 0);
+ fdTabFolder.right = new FormAttachment(100, 0);
+ fdTabFolder.top = new FormAttachment(0, 0);
+ fdTabFolder.bottom = new FormAttachment(100, 0);
+ extraViewTabFolder.setLayoutData(fdTabFolder);
+
extraViewTabFolder.addMouseListener(
new MouseAdapter() {
@@ -3633,13 +3641,6 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
}
});
- FormData fdTabFolder = new FormData();
- fdTabFolder.left = new FormAttachment(0, 0);
- fdTabFolder.right = new FormAttachment(100, 0);
- fdTabFolder.top = new FormAttachment(0, 0);
- fdTabFolder.bottom = new FormAttachment(100, 0);
- extraViewTabFolder.setLayoutData(fdTabFolder);
-
// Create toolbar for close and min/max to the upper right corner...
//
ToolBar extraViewToolBar = new ToolBar(extraViewTabFolder, SWT.FLAT);
@@ -3670,6 +3671,8 @@ 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});
}
@@ -3691,13 +3694,12 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
/** If the extra tab view at the bottom is empty, we close it. */
public void checkEmptyExtraView() {
- if (extraViewTabFolder.getItemCount() == 0) {
+ if (extraViewTabFolder != null && extraViewTabFolder.getItemCount() == 0) {
disposeExtraView();
}
}
private void rotateExtraView() {
-
// Toggle orientation
boolean orientation =
!PropsUi.getInstance().isGraphExtraViewVerticalOrientation();
PropsUi.getInstance().setGraphExtraViewVerticalOrientation(orientation);
@@ -3717,6 +3719,8 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
}
extraViewTabFolder.dispose();
+ extraViewTabFolder = null;
+
sashForm.layout();
sashForm.setWeights(100);
@@ -3733,15 +3737,13 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
//
boolean maximized = sashForm.getMaximizedControl() != null;
if (maximized) {
- // Minimize
- //
+ // Restore
sashForm.setMaximizedControl(null);
minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
minMaxItem.setToolTipText(
BaseMessages.getString(PKG,
"WorkflowGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
} else {
// Maximize
- //
sashForm.setMaximizedControl(extraViewTabFolder);
minMaxItem.setImage(GuiResource.getInstance().getImageMinimizePanel());
minMaxItem.setToolTipText(
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index b3d69cbac5..8aab5e4b41 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -205,6 +205,8 @@ public class ConfigGuiOptionsTab {
Shell shell = wTabFolder.getShell();
PropsUi props = PropsUi.getInstance();
int margin = PropsUi.getMargin();
+ int middle = props.getMiddlePct();
+ int h = (int) (40 * props.getZoomFactor());
CTabItem wLookTab = new CTabItem(wTabFolder, SWT.NONE);
wLookTab.setFont(GuiResource.getInstance().getFontDefault());
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java
new file mode 100644
index 0000000000..2929f8c841
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java
@@ -0,0 +1,698 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.terminal;
+
+import org.apache.commons.lang.StringUtils;
+import org.apache.hop.history.AuditList;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+import org.apache.hop.history.AuditStateMap;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.core.widget.TabFolderReorder;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.perspective.TabClosable;
+import org.apache.hop.ui.hopgui.perspective.TabCloseHandler;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabFolder2Adapter;
+import org.eclipse.swt.custom.CTabFolderEvent;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.ToolBar;
+import org.eclipse.swt.widgets.ToolItem;
+
+/**
+ * Terminal panel for Hop GUI providing integrated command-line access.
+ *
+ * <p>The panel wraps the main perspectives composite in a SashForm, with
perspectives in the top
+ * section and the terminal panel in the bottom section. The terminal panel
persists across
+ * perspective switches.
+ */
+public class HopGuiTerminalPanel extends Composite implements TabClosable {
+
+ private final HopGui hopGui;
+
+ private SashForm verticalSash;
+ private Composite perspectiveComposite;
+ private Composite bottomPanelComposite;
+ private Composite terminalComposite;
+ private CTabFolder terminalTabs;
+ private CTabItem newTerminalTab;
+
+ private boolean terminalVisible = false;
+ private int terminalHeightPercent = 35;
+ private boolean isClearing = false;
+ private int terminalCounter = 1;
+
+ private static final String TERMINAL_AUDIT_TYPE = "terminal";
+
+ // State map keys
+ private static final String STATE_TAB_NAME = "tabName";
+ private static final String STATE_SHELL_PATH = "shellPath";
+ private static final String STATE_WORKING_DIR = "workingDirectory";
+
+ /**
+ * Constructor - Creates the terminal panel structure
+ *
+ * @param parent The parent composite (mainHopGuiComposite from HopGui)
+ * @param hopGui The HopGui instance
+ */
+ public HopGuiTerminalPanel(Composite parent, HopGui hopGui) {
+ super(parent, SWT.NONE);
+ this.hopGui = hopGui;
+
+ createContents();
+ }
+
+ /** Create the UI structure */
+ private void createContents() {
+ setLayout(new FormLayout());
+
+ verticalSash = new SashForm(this, SWT.VERTICAL | SWT.SMOOTH);
+ FormData fdSash = new FormData();
+ fdSash.left = new FormAttachment(0, 0);
+ fdSash.top = new FormAttachment(0, 0);
+ fdSash.right = new FormAttachment(100, 0);
+ fdSash.bottom = new FormAttachment(100, 0);
+ verticalSash.setLayoutData(fdSash);
+
+ perspectiveComposite = new Composite(verticalSash, SWT.NONE);
+ perspectiveComposite.setLayout(new FormLayout());
+
+ bottomPanelComposite = new Composite(verticalSash, SWT.NONE);
+ bottomPanelComposite.setLayout(new FormLayout());
+ createBottomPanel();
+
+ verticalSash.setMaximizedControl(perspectiveComposite);
+ }
+
+ /** Create the bottom panel with terminal */
+ private void createBottomPanel() {
+ // Terminal area directly in bottom panel composite
+ terminalComposite = new Composite(bottomPanelComposite, SWT.NONE);
+ terminalComposite.setLayout(new FormLayout());
+
+ FormData fdTerminal = new FormData();
+ fdTerminal.left = new FormAttachment(0, 0);
+ fdTerminal.top = new FormAttachment(0, 0);
+ fdTerminal.right = new FormAttachment(100, 0);
+ fdTerminal.bottom = new FormAttachment(100, 0);
+ terminalComposite.setLayoutData(fdTerminal);
+
+ createTerminalArea();
+ }
+
+ /** Create the terminal area with tab folder */
+ private void createTerminalArea() {
+ terminalTabs = new CTabFolder(terminalComposite, SWT.MULTI | SWT.BORDER);
+ PropsUi.setLook(terminalTabs, PropsUi.WIDGET_STYLE_TAB);
+ FormData fdTabs = new FormData();
+ fdTabs.left = new FormAttachment(0, 0);
+ fdTabs.top = new FormAttachment(0, 0);
+ fdTabs.right = new FormAttachment(100, 0);
+ fdTabs.bottom = new FormAttachment(100, 0);
+ terminalTabs.setLayoutData(fdTabs);
+
+ createTerminalToolbar();
+
+ newTerminalTab = new CTabItem(terminalTabs, SWT.NONE);
+ newTerminalTab.setText("+");
+ newTerminalTab.setToolTipText("Create a new terminal");
+ Composite newTerminalPlaceholder = new Composite(terminalTabs, SWT.NONE);
+ newTerminalTab.setControl(newTerminalPlaceholder);
+
+ new TabCloseHandler(this);
+ new TabFolderReorder(terminalTabs);
+
+ final boolean[] isClosingTab = {false};
+ terminalTabs.addListener(
+ SWT.Selection,
+ event -> {
+ CTabItem item = terminalTabs.getSelection();
+ if (item == newTerminalTab) {
+ if (isClearing || isClosingTab[0]) {
+ return;
+ }
+ createNewTerminal(null, null);
+ return;
+ }
+
+ if (item != null) {
+ ITerminalWidget widget = (ITerminalWidget)
item.getData("terminalWidget");
+ if (widget != null && widget instanceof JediTerminalWidget) {
+ Composite composite = widget.getTerminalComposite();
+ if (composite != null && !composite.isDisposed()) {
+ composite.forceFocus();
+ }
+ }
+ }
+ });
+
+ terminalTabs.addCTabFolder2Listener(
+ new CTabFolder2Adapter() {
+ @Override
+ public void close(CTabFolderEvent event) {
+ isClosingTab[0] = true;
+ try {
+ CTabItem item = (CTabItem) event.item;
+ if (item == newTerminalTab) {
+ event.doit = false;
+ return;
+ }
+ closeTab(event, item);
+ } finally {
+ getDisplay()
+ .asyncExec(
+ () -> {
+ isClosingTab[0] = false;
+ });
+ }
+ }
+ });
+ terminalTabs.addListener(
+ SWT.MouseDoubleClick,
+ event -> {
+ CTabItem item = terminalTabs.getSelection();
+ if (item != null && item != newTerminalTab) {
+ renameTerminalTab(item);
+ }
+ });
+ }
+
+ public void createNewTerminal(String workingDirectory, String shellPath) {
+ createNewTerminal(workingDirectory, shellPath, null);
+ }
+
+ public void createNewTerminal(String workingDirectory, String shellPath,
String customTabName) {
+ if (shellPath == null) {
+ shellPath = TerminalShellDetector.detectDefaultShell();
+ }
+
+ if (workingDirectory == null) {
+ workingDirectory = getDefaultWorkingDirectory();
+ }
+
+ CTabItem terminalTab = new CTabItem(terminalTabs, SWT.CLOSE, 1);
+
+ String terminalId = "terminal-" + terminalCounter++ + "-" +
System.currentTimeMillis();
+
+ if (customTabName != null && !customTabName.trim().isEmpty()) {
+ terminalTab.setText(customTabName);
+ } else {
+ String shellName = extractShellName(shellPath);
+ terminalTab.setText(shellName + " (" + (terminalCounter - 1) + ")");
+ }
+ terminalTab.setImage(GuiResource.getInstance().getImageTerminal());
+ terminalTab.setToolTipText("Terminal: " + shellPath + " in " +
workingDirectory);
+
+ terminalTab.setData("terminalId", terminalId);
+ terminalTab.setData("workingDirectory", workingDirectory);
+ terminalTab.setData("shellPath", shellPath);
+
+ Composite terminalWidgetComposite = new Composite(terminalTabs, SWT.NONE);
+ terminalWidgetComposite.setLayout(new FormLayout());
+ terminalTab.setControl(terminalWidgetComposite);
+
+ ITerminalWidget terminalWidget =
+ new JediTerminalWidget(terminalWidgetComposite, shellPath,
workingDirectory);
+
+ terminalTab.setData("terminalWidget", terminalWidget);
+
+ updateTabTextWithTerminalType(terminalTab, terminalWidget);
+
+ registerTerminal(terminalId, workingDirectory, shellPath);
+
+ terminalTabs.setSelection(terminalTab);
+
+ if (!terminalVisible) {
+ showTerminal();
+ }
+
+ getDisplay()
+ .asyncExec(
+ () -> {
+ if (terminalWidget == null) {
+ return;
+ }
+
+ if (terminalWidget instanceof JediTerminalWidget) {
+ Composite composite = terminalWidget.getTerminalComposite();
+ if (composite != null && !composite.isDisposed()) {
+ composite.setFocus();
+ composite.forceFocus();
+ }
+ }
+ });
+ }
+
+ /** Extract shell name from full path (e.g., "/bin/bash" -> "bash") */
+ private String extractShellName(String shellPath) {
+ if (shellPath == null || shellPath.isEmpty()) {
+ return "Terminal";
+ }
+
+ // Handle Windows paths
+ if (shellPath.contains("\\")) {
+ int lastBackslash = shellPath.lastIndexOf('\\');
+ shellPath = shellPath.substring(lastBackslash + 1);
+ }
+
+ // Handle Unix paths
+ if (shellPath.contains("/")) {
+ int lastSlash = shellPath.lastIndexOf('/');
+ shellPath = shellPath.substring(lastSlash + 1);
+ }
+
+ // Remove .exe extension
+ if (shellPath.endsWith(".exe")) {
+ shellPath = shellPath.substring(0, shellPath.length() - 4);
+ }
+
+ return shellPath;
+ }
+
+ private void updateTabTextWithTerminalType(CTabItem terminalTab,
ITerminalWidget terminalWidget) {
+ if (terminalTab == null || terminalWidget == null) {
+ return;
+ }
+
+ String currentText = terminalTab.getText();
+ String indicator = " [JT]";
+
+ if (!currentText.contains(indicator)) {
+ terminalTab.setText(currentText + indicator);
+ }
+ }
+
+ /** Show the terminal panel */
+ public void showTerminal() {
+ if (!terminalVisible) {
+ verticalSash.setMaximizedControl(null);
+ int perspectivePercent = 100 - terminalHeightPercent;
+ verticalSash.setWeights(new int[] {perspectivePercent,
terminalHeightPercent});
+ terminalVisible = true;
+
+ if (terminalTabs.getItemCount() <= 1) {
+ createNewTerminal(null, null);
+ }
+
+ layout(true, true);
+ }
+ }
+
+ /** Check if terminal panel is currently visible */
+ public boolean isTerminalVisible() {
+ return terminalVisible;
+ }
+
+ /** Hide the terminal panel */
+ public void hideTerminal() {
+ if (terminalVisible) {
+ terminalVisible = false;
+ verticalSash.setMaximizedControl(perspectiveComposite);
+ layout(true, true);
+ }
+ }
+
+ /** Toggle terminal panel visibility */
+ public void toggleTerminal() {
+ if (terminalVisible) {
+ hideTerminal();
+ } else {
+ showTerminal();
+ }
+ }
+
+ /** Close a terminal tab (implements TabClosable interface) */
+ @Override
+ public void closeTab(CTabFolderEvent event, CTabItem tabItem) {
+ if (tabItem == newTerminalTab) {
+ if (event != null) {
+ event.doit = false;
+ }
+ return;
+ }
+
+ ITerminalWidget widget = (ITerminalWidget)
tabItem.getData("terminalWidget");
+ if (widget != null) {
+ widget.dispose();
+ }
+
+ String terminalId = (String) tabItem.getData("terminalId");
+ if (terminalId != null) {
+ unregisterTerminal(terminalId);
+ }
+
+ tabItem.dispose();
+ }
+
+ /** Get the terminal tabs folder (implements TabClosable interface) */
+ @Override
+ public CTabFolder getTabFolder() {
+ return terminalTabs;
+ }
+
+ /** Get all tabs to the right (excluding the + tab) */
+ @Override
+ public java.util.List<CTabItem> getTabsToRight(CTabItem selectedTabItem) {
+ java.util.List<CTabItem> items = new java.util.ArrayList<>();
+ for (int i = getTabFolder().getItems().length - 1; i >= 0; i--) {
+ CTabItem item = getTabFolder().getItems()[i];
+ if (selectedTabItem.equals(item)) {
+ break;
+ } else if (item != newTerminalTab) {
+ items.add(item);
+ }
+ }
+ return items;
+ }
+
+ /** Get all tabs to the left (excluding the + tab) */
+ @Override
+ public java.util.List<CTabItem> getTabsToLeft(CTabItem selectedTabItem) {
+ java.util.List<CTabItem> items = new java.util.ArrayList<>();
+ for (CTabItem item : getTabFolder().getItems()) {
+ if (selectedTabItem.equals(item)) {
+ break;
+ } else if (item != newTerminalTab) {
+ items.add(item);
+ }
+ }
+ return items;
+ }
+
+ /** Get all other tabs (excluding the + tab) */
+ @Override
+ public java.util.List<CTabItem> getOtherTabs(CTabItem selectedTabItem) {
+ java.util.List<CTabItem> items = new java.util.ArrayList<>();
+ for (CTabItem item : getTabFolder().getItems()) {
+ if (!selectedTabItem.equals(item) && item != newTerminalTab) {
+ items.add(item);
+ }
+ }
+ return items;
+ }
+
+ /** Create toolbar with panel controls (maximize/minimize, close) */
+ private void createTerminalToolbar() {
+ ToolBar toolBar = new ToolBar(terminalTabs, SWT.FLAT);
+ terminalTabs.setTopRight(toolBar, SWT.RIGHT);
+ PropsUi.setLook(toolBar);
+
+ GuiResource gui = GuiResource.getInstance();
+ if (PropsUi.getInstance().isDarkMode()) {
+ toolBar.setBackground(gui.getColorWhite());
+ } else {
+ toolBar.setBackground(terminalTabs.getBackground());
+ }
+
+ // Maximize/Minimize button
+ final ToolItem maximizeItem = new ToolItem(toolBar, SWT.PUSH);
+ maximizeItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
+ maximizeItem.setToolTipText("Maximize terminal panel");
+ maximizeItem.addListener(
+ SWT.Selection,
+ e -> {
+ if (verticalSash.getMaximizedControl() == null) {
+ // Maximize terminal panel
+ verticalSash.setMaximizedControl(bottomPanelComposite);
+
maximizeItem.setImage(GuiResource.getInstance().getImageMinimizePanel());
+ maximizeItem.setToolTipText("Restore terminal panel");
+ } else {
+ // Restore normal split
+ verticalSash.setMaximizedControl(null);
+ verticalSash.setWeights(new int[] {100 - terminalHeightPercent,
terminalHeightPercent});
+
maximizeItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
+ maximizeItem.setToolTipText("Maximize terminal panel");
+ }
+ });
+
+ // Close button
+ final ToolItem closeItem = new ToolItem(toolBar, SWT.PUSH);
+ closeItem.setImage(GuiResource.getInstance().getImageClose());
+ closeItem.setToolTipText("Close terminal panel");
+ closeItem.addListener(SWT.Selection, e -> hideTerminal());
+
+ int height = toolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
+ terminalTabs.setTabHeight(Math.max(height, terminalTabs.getTabHeight()));
+ }
+
+ /** Rename a terminal tab via dialog */
+ private void renameTerminalTab(CTabItem item) {
+ if (item == null || item == newTerminalTab) {
+ return;
+ }
+
+ final Text text = new Text(terminalTabs, SWT.BORDER);
+ text.setText(item.getText());
+
+ org.eclipse.swt.graphics.Rectangle bounds = item.getBounds();
+ text.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
+ text.moveAbove(null);
+
+ text.setFocus();
+ text.selectAll();
+
+ text.addListener(
+ SWT.Traverse,
+ event -> {
+ if (event.detail == SWT.TRAVERSE_RETURN) {
+ String newName = text.getText().trim();
+ if (!newName.isEmpty()) {
+ item.setText(newName);
+ saveOpenTerminals();
+ }
+ text.dispose();
+ event.doit = false;
+ } else if (event.detail == SWT.TRAVERSE_ESCAPE) {
+ text.dispose();
+ event.doit = false;
+ }
+ });
+
+ text.addListener(
+ SWT.FocusOut,
+ event -> {
+ if (!text.isDisposed()) {
+ String newName = text.getText().trim();
+ if (!newName.isEmpty()) {
+ item.setText(newName);
+ saveOpenTerminals();
+ }
+ text.dispose();
+ }
+ });
+ }
+
+ /** Save terminals on shutdown */
+ public void saveTerminalsOnShutdown() {
+ saveOpenTerminals();
+ }
+
+ /** Save all open terminals */
+ private void saveOpenTerminals() {
+ try {
+ java.util.List<String> terminalIds = new java.util.ArrayList<>();
+ AuditStateMap stateMap = new AuditStateMap();
+
+ for (CTabItem item : terminalTabs.getItems()) {
+ if (item == newTerminalTab) {
+ continue;
+ }
+ String terminalId = (String) item.getData("terminalId");
+ if (terminalId != null) {
+ terminalIds.add(terminalId);
+
+ java.util.Map<String, Object> state = new java.util.HashMap<>();
+ state.put(STATE_TAB_NAME, item.getText());
+ state.put(STATE_WORKING_DIR, item.getData("workingDirectory"));
+ state.put(STATE_SHELL_PATH, item.getData("shellPath"));
+
+ stateMap.add(new AuditState(terminalId, state));
+ }
+ }
+
+ AuditList auditList = new AuditList(terminalIds);
+ AuditManager.getActive()
+ .storeList(HopNamespace.getNamespace(), TERMINAL_AUDIT_TYPE,
auditList);
+
+ AuditManager.getActive()
+ .saveAuditStateMap(HopNamespace.getNamespace(), TERMINAL_AUDIT_TYPE,
stateMap);
+
+ hopGui
+ .getLog()
+ .logDebug("Saved " + terminalIds.size() + " open terminal(s) for
current project");
+ } catch (Exception e) {
+ hopGui.getLog().logError("Error saving open terminals", e);
+ }
+ }
+
+ /** Clear all terminals */
+ public void clearAllTerminals() {
+ if (isDisposed() || terminalTabs == null || terminalTabs.isDisposed()) {
+ hopGui.getLog().logDebug("clearAllTerminals: skipped (disposed or not
initialized)");
+ return;
+ }
+
+ isClearing = true;
+
+ try {
+ saveOpenTerminals();
+
+ java.util.List<CTabItem> itemsToClose = new java.util.ArrayList<>();
+ for (CTabItem item : terminalTabs.getItems()) {
+ if (item != newTerminalTab && !item.isDisposed()) {
+ itemsToClose.add(item);
+ }
+ }
+
+ for (CTabItem item : itemsToClose) {
+ if (!item.isDisposed()) {
+ ITerminalWidget widget = (ITerminalWidget)
item.getData("terminalWidget");
+ if (widget != null) {
+ widget.dispose();
+ }
+ item.dispose();
+ }
+ }
+
+ if (terminalVisible) {
+ hideTerminal();
+ }
+ } finally {
+ isClearing = false;
+ }
+ }
+
+ private void registerTerminal(String terminalId, String workingDirectory,
String shellPath) {
+ // Terminal state is saved on shutdown
+ }
+
+ private void unregisterTerminal(String terminalId) {
+ saveOpenTerminals();
+ }
+
+ private String getDefaultWorkingDirectory() {
+ try {
+ String projectHome = hopGui.getVariables().getVariable("PROJECT_HOME");
+ if (StringUtils.isNotEmpty(projectHome)) {
+ projectHome = hopGui.getVariables().resolve(projectHome);
+ if (StringUtils.isNotEmpty(projectHome)) {
+ return projectHome;
+ }
+ }
+ } catch (Exception e) {
+ // Ignore
+ }
+
+ return System.getProperty("user.home");
+ }
+
+ /** Restore terminals from previous session */
+ public void restoreTerminals() {
+ try {
+ String namespace = HopNamespace.getNamespace();
+
+ int existingCount = 0;
+ for (CTabItem item : terminalTabs.getItems()) {
+ if (item != newTerminalTab) {
+ existingCount++;
+ }
+ }
+
+ if (existingCount > 0) {
+ return;
+ }
+
+ AuditList auditList = AuditManager.getActive().retrieveList(namespace,
TERMINAL_AUDIT_TYPE);
+
+ if (auditList.getNames().isEmpty()) {
+ return;
+ }
+
+ AuditStateMap stateMap;
+ try {
+ stateMap =
+ AuditManager.getActive()
+ .loadAuditStateMap(HopNamespace.getNamespace(),
TERMINAL_AUDIT_TYPE);
+ } catch (Exception e) {
+ hopGui.getLog().logError("Error loading terminal state map", e);
+ stateMap = new AuditStateMap();
+ }
+
+ for (String terminalId : auditList.getNames()) {
+ String customTabName = null;
+ String workingDir = null;
+ String shellPath = null;
+
+ AuditState state = stateMap.get(terminalId);
+ if (state != null && state.getStateMap() != null) {
+ Object tabNameObj = state.getStateMap().get(STATE_TAB_NAME);
+ if (tabNameObj != null) {
+ customTabName = tabNameObj.toString();
+ }
+ Object workingDirObj = state.getStateMap().get(STATE_WORKING_DIR);
+ if (workingDirObj != null) {
+ workingDir = workingDirObj.toString();
+ }
+ Object shellPathObj = state.getStateMap().get(STATE_SHELL_PATH);
+ if (shellPathObj != null) {
+ shellPath = shellPathObj.toString();
+ }
+ }
+
+ createNewTerminal(workingDir, shellPath, customTabName);
+ }
+ } catch (Exception e) {
+ hopGui.getLog().logError("Error restoring terminals", e);
+ }
+ }
+
+ /** Get the perspective composite */
+ public Composite getPerspectiveComposite() {
+ return perspectiveComposite;
+ }
+
+ /** Get the terminal tabs folder */
+ public CTabFolder getTerminalTabs() {
+ return terminalTabs;
+ }
+
+ /** Set terminal height percentage */
+ public void setTerminalHeightPercent(int percent) {
+ if (percent > 0 && percent < 100) {
+ this.terminalHeightPercent = percent;
+ if (terminalVisible) {
+ int perspectivePercent = 100 - terminalHeightPercent;
+ verticalSash.setWeights(new int[] {perspectivePercent,
terminalHeightPercent});
+ }
+ }
+ }
+
+ /** Get current terminal height percentage */
+ public int getTerminalHeightPercent() {
+ return terminalHeightPercent;
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/ITerminalWidget.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/ITerminalWidget.java
new file mode 100644
index 0000000000..042615b213
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/ITerminalWidget.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.terminal;
+
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.widgets.Composite;
+
+/** Common interface for terminal widget implementations. */
+public interface ITerminalWidget {
+
+ /**
+ * Get the terminal composite widget
+ *
+ * @return The SWT composite containing the terminal
+ */
+ Composite getTerminalComposite();
+
+ /** Dispose of terminal resources */
+ void dispose();
+
+ /**
+ * Check if terminal is connected/running
+ *
+ * @return true if terminal process is alive
+ */
+ boolean isConnected();
+
+ /**
+ * Get the shell path being used
+ *
+ * @return Path to shell executable (e.g. /bin/zsh)
+ */
+ String getShellPath();
+
+ /**
+ * Get the working directory
+ *
+ * @return Current working directory
+ */
+ String getWorkingDirectory();
+
+ /**
+ * Get the output text widget for focus/copy operations
+ *
+ * @return The StyledText widget showing output
+ */
+ StyledText getOutputText();
+
+ /**
+ * Get terminal type description
+ *
+ * @return Terminal type string
+ */
+ String getTerminalType();
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/JediTerminalWidget.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/JediTerminalWidget.java
new file mode 100644
index 0000000000..b57b75e54e
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/JediTerminalWidget.java
@@ -0,0 +1,451 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.terminal;
+
+import com.jediterm.terminal.TerminalColor;
+import com.jediterm.terminal.TextStyle;
+import com.jediterm.terminal.TtyConnector;
+import com.jediterm.terminal.ui.JediTermWidget;
+import com.jediterm.terminal.ui.settings.DefaultSettingsProvider;
+import com.pty4j.PtyProcess;
+import com.pty4j.PtyProcessBuilder;
+import com.pty4j.WinSize;
+import java.awt.Frame;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.ui.core.PropsUi;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.awt.SWT_AWT;
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.graphics.FontData;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
+
+/** JediTerm-based terminal widget using SWT-AWT bridge for embedding AWT
components in SWT. */
+public class JediTerminalWidget implements ITerminalWidget {
+
+ private static final LogChannel log = new LogChannel("JediTerminal");
+
+ private final String shellPath;
+ private final String workingDirectory;
+
+ private Composite bridgeComposite;
+ private Frame awtFrame;
+ private JediTermWidget jediTermWidget;
+ private PtyProcess ptyProcess;
+ private Pty4JTtyConnector ttyConnector;
+
+ public JediTerminalWidget(Composite parent, String shellPath, String
workingDirectory) {
+ this.shellPath = shellPath;
+ this.workingDirectory = workingDirectory;
+
+ createWidget(parent, parent.getDisplay());
+
+ // Defer shell process start to avoid blocking UI
+ parent
+ .getDisplay()
+ .asyncExec(
+ () -> {
+ if (!bridgeComposite.isDisposed()) {
+ startShellProcess();
+ }
+ });
+ }
+
+ public void createWidget(Composite parent, Display display) {
+ // Create SWT_AWT bridge composite
+ bridgeComposite = new Composite(parent, SWT.EMBEDDED | SWT.NO_BACKGROUND);
+ PropsUi.setLook(bridgeComposite);
+
+ // Layout the bridge composite to fill parent
+ FormData fd = new FormData();
+ fd.left = new FormAttachment(0, 0);
+ fd.right = new FormAttachment(100, 0);
+ fd.top = new FormAttachment(0, 0);
+ fd.bottom = new FormAttachment(100, 0);
+ bridgeComposite.setLayoutData(fd);
+
+ // Create AWT Frame for JediTerm
+ awtFrame = SWT_AWT.new_Frame(bridgeComposite);
+
+ // Resize AWT frame when composite resizes
+ bridgeComposite.addListener(
+ SWT.Resize,
+ event -> {
+ if (bridgeComposite.isDisposed() || awtFrame == null) {
+ return;
+ }
+ try {
+ Rectangle rect = bridgeComposite.getClientArea();
+ java.awt.EventQueue.invokeLater(
+ () -> {
+ if (awtFrame != null) {
+ try {
+ awtFrame.setSize(rect.width, rect.height);
+ awtFrame.validate();
+ } catch (Exception e) {
+ log.logDebug("Error resizing AWT frame: " +
e.getMessage());
+ }
+ }
+ });
+ } catch (Exception e) {
+ log.logDebug("Error in resize handler: " + e.getMessage());
+ }
+ });
+
+ // Create JediTerm widget with Hop dark mode support
+ boolean isDarkMode = PropsUi.getInstance().isDarkMode();
+ DefaultSettingsProvider settings = createHopSettingsProvider(isDarkMode,
display);
+ jediTermWidget = new JediTermWidget(settings);
+ awtFrame.add(jediTermWidget);
+
+ awtFrame.setVisible(true);
+
+ // Defer focus until after terminal initialization
+ bridgeComposite.addListener(
+ org.eclipse.swt.SWT.FocusIn,
+ event -> {
+ display.asyncExec(
+ () -> {
+ if (bridgeComposite.isDisposed() || jediTermWidget == null) {
+ return;
+ }
+ new Thread(
+ () -> {
+ try {
+ java.awt.EventQueue.invokeLater(
+ () -> {
+ if (jediTermWidget != null) {
+ try {
+ jediTermWidget.requestFocusInWindow();
+ } catch (Exception e) {
+ log.logDebug("Could not request focus: "
+ e.getMessage());
+ }
+ }
+ });
+ } catch (Exception e) {
+ log.logDebug("Error requesting AWT focus: " +
e.getMessage());
+ }
+ })
+ .start();
+ });
+ });
+ }
+
+ /** Create SettingsProvider with Hop theme and font scaling */
+ private DefaultSettingsProvider createHopSettingsProvider(
+ final boolean isDarkMode, final Display display) {
+ // Get the SWT system font size as base
+ FontData systemFontData = display.getSystemFont().getFontData()[0];
+ int swtFontSize = systemFontData.getHeight();
+
+ // Get font scale from system property, default 1.0
+ float fontScale = 1.0f;
+ String manualScale = System.getProperty("JediTerm.fontScale");
+ if (manualScale != null && !manualScale.isEmpty()) {
+ try {
+ fontScale = Float.parseFloat(manualScale);
+ log.logBasic("JediTerm: Using font scale from system property: " +
fontScale);
+ } catch (NumberFormatException e) {
+ log.logError("JediTerm: Invalid JediTerm.fontScale value: " +
manualScale, e);
+ }
+ }
+
+ final int targetFontSize = Math.round(swtFontSize * fontScale);
+
+ return new DefaultSettingsProvider() {
+ @Override
+ public TerminalColor getDefaultBackground() {
+ if (Const.isWindows() && !isDarkMode) {
+ // Windows light mode: PowerShell blue background
+ return new TerminalColor(1, 36, 86);
+ } else if (Const.isWindows() && isDarkMode) {
+ // Windows dark mode: dark gray background
+ return new TerminalColor(43, 43, 43);
+ } else if (isDarkMode) {
+ // Mac/Linux dark mode: dark gray background
+ return new TerminalColor(43, 43, 43);
+ } else {
+ // Mac/Linux light mode: white background
+ return new TerminalColor(255, 255, 255);
+ }
+ }
+
+ @Override
+ public TerminalColor getDefaultForeground() {
+ if (Const.isWindows() && !isDarkMode) {
+ // Windows light mode: PowerShell gray foreground
+ return new TerminalColor(204, 204, 204);
+ } else if (Const.isWindows() && isDarkMode) {
+ // Windows dark mode: light gray foreground
+ return new TerminalColor(187, 187, 187);
+ } else if (isDarkMode) {
+ // Mac/Linux dark mode: light gray foreground
+ return new TerminalColor(187, 187, 187);
+ } else {
+ // Mac/Linux light mode: black foreground
+ return new TerminalColor(0, 0, 0);
+ }
+ }
+
+ @Override
+ public TextStyle getFoundPatternColor() {
+ TerminalColor bg = new TerminalColor(255, 255, 0);
+ TerminalColor fg = new TerminalColor(0, 0, 0);
+ return new TextStyle(fg, bg);
+ }
+
+ @Override
+ public TextStyle getHyperlinkColor() {
+ TerminalColor linkColor =
+ isDarkMode ? new TerminalColor(96, 161, 255) : new
TerminalColor(0, 0, 238);
+ return new TextStyle(linkColor, null);
+ }
+
+ @Override
+ public java.awt.Font getTerminalFont() {
+ return new java.awt.Font("Monospaced", java.awt.Font.PLAIN,
targetFontSize);
+ }
+
+ @Override
+ public float getTerminalFontSize() {
+ return (float) targetFontSize;
+ }
+ };
+ }
+
+ public void startShellProcess() {
+ try {
+ // Build PTY process
+ String[] command = getShellCommand();
+ Map<String, String> env = new HashMap<>(System.getenv());
+ env.put("TERM", "xterm-256color");
+
+ PtyProcessBuilder builder =
+ new PtyProcessBuilder()
+ .setCommand(command)
+ .setDirectory(workingDirectory)
+ .setEnvironment(env)
+ .setInitialColumns(80)
+ .setInitialRows(24);
+
+ ptyProcess = builder.start();
+
+ // Create connector to bridge Pty4J and JediTerm
+ ttyConnector = new Pty4JTtyConnector(ptyProcess);
+
+ // Start the terminal session
+ jediTermWidget.setTtyConnector(ttyConnector);
+ jediTermWidget.start();
+
+ // Request focus after terminal initialization
+ new Thread(
+ () -> {
+ try {
+ Thread.sleep(100);
+ java.awt.EventQueue.invokeLater(
+ () -> {
+ if (jediTermWidget != null) {
+ try {
+ jediTermWidget.requestFocusInWindow();
+ } catch (Exception e) {
+ log.logDebug("Could not request initial focus: " +
e.getMessage());
+ }
+ }
+ });
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ log.logDebug("Error requesting initial AWT focus: " +
e.getMessage());
+ }
+ })
+ .start();
+
+ } catch (Exception e) {
+ log.logError("Error starting JediTerm shell process", e);
+ }
+ }
+
+ private String[] getShellCommand() {
+ String shell = shellPath != null ? shellPath :
TerminalShellDetector.detectDefaultShell();
+
+ if (Const.isWindows()) {
+ if (shell.toLowerCase().contains("powershell")) {
+ return new String[] {shell, "-NoLogo"};
+ } else {
+ return new String[] {shell};
+ }
+ } else {
+ return new String[] {shell, "-i", "-l"};
+ }
+ }
+
+ public void sendRawInput(String input) {
+ // JediTerm handles input directly
+ }
+
+ @Override
+ public void dispose() {
+ try {
+ if (bridgeComposite != null && !bridgeComposite.isDisposed()) {
+ bridgeComposite.dispose();
+ }
+
+ // Clean up PTY in background thread
+ final JediTermWidget termWidget = jediTermWidget;
+ final Pty4JTtyConnector connector = ttyConnector;
+ final PtyProcess process = ptyProcess;
+
+ new Thread(
+ () -> {
+ try {
+ if (termWidget != null) {
+ termWidget.stop();
+ }
+ if (connector != null) {
+ connector.close();
+ }
+ if (process != null && process.isAlive()) {
+ process.destroy();
+ }
+ } catch (Exception e) {
+ log.logError("Error cleaning up JediTerm PTY", e);
+ }
+ })
+ .start();
+
+ } catch (Exception e) {
+ log.logError("Error disposing JediTerm", e);
+ }
+ }
+
+ @Override
+ public Composite getTerminalComposite() {
+ return bridgeComposite;
+ }
+
+ @Override
+ public StyledText getOutputText() {
+ // JediTerm uses AWT, not StyledText
+ return null;
+ }
+
+ @Override
+ public String getTerminalType() {
+ return "JediTerm (POC)";
+ }
+
+ @Override
+ public boolean isConnected() {
+ return ptyProcess != null && ptyProcess.isAlive();
+ }
+
+ @Override
+ public String getShellPath() {
+ return shellPath;
+ }
+
+ @Override
+ public String getWorkingDirectory() {
+ return workingDirectory;
+ }
+
+ /** Adapter connecting Pty4J to JediTerm's TtyConnector */
+ private static class Pty4JTtyConnector implements TtyConnector {
+
+ private final PtyProcess ptyProcess;
+ private final InputStream inputStream;
+ private final OutputStream outputStream;
+
+ public Pty4JTtyConnector(PtyProcess ptyProcess) {
+ this.ptyProcess = ptyProcess;
+ this.inputStream = ptyProcess.getInputStream();
+ this.outputStream = ptyProcess.getOutputStream();
+ }
+
+ public boolean init() {
+ return true;
+ }
+
+ public void close() {
+ try {
+ if (ptyProcess != null && ptyProcess.isAlive()) {
+ ptyProcess.destroy();
+ }
+ } catch (Exception e) {
+ log.logError("Error closing Pty4J connector", e);
+ }
+ }
+
+ public String getName() {
+ return "Pty4J Connector";
+ }
+
+ public int read(char[] buf, int offset, int length) throws IOException {
+ byte[] bytes = new byte[length];
+ int bytesRead = inputStream.read(bytes, 0, length);
+ if (bytesRead > 0) {
+ String str = new String(bytes, 0, bytesRead, StandardCharsets.UTF_8);
+ char[] chars = str.toCharArray();
+ System.arraycopy(chars, 0, buf, offset, Math.min(chars.length,
length));
+ return chars.length;
+ }
+ return bytesRead;
+ }
+
+ public void write(byte[] bytes) throws IOException {
+ outputStream.write(bytes);
+ outputStream.flush();
+ }
+
+ public boolean isConnected() {
+ return ptyProcess != null && ptyProcess.isAlive();
+ }
+
+ public void write(String string) throws IOException {
+ write(string.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public int waitFor() throws InterruptedException {
+ return ptyProcess.waitFor();
+ }
+
+ public boolean ready() throws IOException {
+ return inputStream.available() > 0;
+ }
+
+ public void resize(int cols, int rows) {
+ if (ptyProcess != null && ptyProcess.isAlive()) {
+ try {
+ ptyProcess.setWinSize(new WinSize(cols, rows));
+ } catch (Exception e) {
+ log.logError("Error resizing PTY", e);
+ }
+ }
+ }
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetector.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetector.java
new file mode 100644
index 0000000000..0124cb395a
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetector.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.terminal;
+
+import java.io.File;
+
+/** Utility class to detect the default shell for the current operating
system. */
+public class TerminalShellDetector {
+
+ /**
+ * Detect the default shell for the current OS
+ *
+ * @return Full path to the shell executable
+ */
+ public static String detectDefaultShell() {
+ String os = System.getProperty("os.name").toLowerCase();
+
+ if (os.contains("win")) {
+ return detectWindowsShell();
+ } else if (os.contains("mac")) {
+ return detectMacShell();
+ } else {
+ return detectLinuxShell();
+ }
+ }
+
+ /** Detect shell on Windows */
+ private static String detectWindowsShell() {
+ String programFiles = System.getenv("PROGRAMFILES");
+ if (programFiles != null) {
+ String pwsh7 = programFiles + "\\PowerShell\\7\\pwsh.exe";
+ if (new File(pwsh7).exists()) {
+ return pwsh7;
+ }
+ }
+
+ String windir = System.getenv("WINDIR");
+ if (windir != null) {
+ String powershell = windir +
"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
+ if (new File(powershell).exists()) {
+ return powershell;
+ }
+ }
+
+ return "cmd.exe";
+ }
+
+ /** Detect shell on macOS */
+ private static String detectMacShell() {
+ String shell = System.getenv("SHELL");
+ if (shell != null && !shell.isEmpty() && new File(shell).exists()) {
+ return shell;
+ }
+
+ if (new File("/bin/zsh").exists()) {
+ return "/bin/zsh";
+ }
+
+ if (new File("/bin/bash").exists()) {
+ return "/bin/bash";
+ }
+
+ return "/bin/sh";
+ }
+
+ /** Detect shell on Linux/Unix */
+ private static String detectLinuxShell() {
+ String shell = System.getenv("SHELL");
+ if (shell != null && !shell.isEmpty() && new File(shell).exists()) {
+ return shell;
+ }
+
+ String[] commonShells = {
+ "/bin/bash", "/usr/bin/bash", "/bin/zsh", "/usr/bin/zsh", "/bin/sh",
"/usr/bin/sh"
+ };
+
+ for (String shellPath : commonShells) {
+ if (new File(shellPath).exists()) {
+ return shellPath;
+ }
+ }
+
+ return "/bin/sh";
+ }
+
+ /**
+ * Check if a shell path exists and is executable
+ *
+ * @param shellPath Path to shell executable
+ * @return true if shell exists and can be executed
+ */
+ public static boolean isValidShell(String shellPath) {
+ if (shellPath == null || shellPath.isEmpty()) {
+ return false;
+ }
+
+ File shellFile = new File(shellPath);
+ return shellFile.exists() && shellFile.canExecute();
+ }
+
+ /**
+ * Get a list of available shells on the system
+ *
+ * @return Array of shell paths that exist on this system
+ */
+ public static String[] getAvailableShells() {
+ String os = System.getProperty("os.name").toLowerCase();
+
+ if (os.contains("win")) {
+ return getWindowsShells();
+ } else {
+ return getUnixShells();
+ }
+ }
+
+ private static String[] getWindowsShells() {
+ java.util.List<String> shells = new java.util.ArrayList<>();
+
+ // PowerShell 7
+ String programFiles = System.getenv("PROGRAMFILES");
+ if (programFiles != null) {
+ String pwsh7 = programFiles + "\\PowerShell\\7\\pwsh.exe";
+ if (new File(pwsh7).exists()) {
+ shells.add(pwsh7);
+ }
+ }
+
+ // PowerShell 5.x
+ String windir = System.getenv("WINDIR");
+ if (windir != null) {
+ String powershell = windir +
"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
+ if (new File(powershell).exists()) {
+ shells.add(powershell);
+ }
+ }
+
+ // cmd.exe
+ shells.add("cmd.exe");
+
+ // Git Bash (if installed)
+ String[] gitBashPaths = {
+ programFiles + "\\Git\\bin\\bash.exe",
+ "C:\\Program Files\\Git\\bin\\bash.exe",
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe"
+ };
+ for (String path : gitBashPaths) {
+ if (new File(path).exists()) {
+ shells.add(path);
+ break; // Only add once
+ }
+ }
+
+ return shells.toArray(new String[0]);
+ }
+
+ private static String[] getUnixShells() {
+ java.util.List<String> shells = new java.util.ArrayList<>();
+
+ String[] commonShells = {
+ "/bin/bash",
+ "/usr/bin/bash",
+ "/bin/zsh",
+ "/usr/bin/zsh",
+ "/bin/fish",
+ "/usr/bin/fish",
+ "/bin/sh",
+ "/usr/bin/sh",
+ "/bin/dash",
+ "/bin/ksh",
+ "/bin/tcsh",
+ "/bin/csh"
+ };
+
+ for (String shell : commonShells) {
+ if (new File(shell).exists()) {
+ shells.add(shell);
+ }
+ }
+
+ return shells.toArray(new String[0]);
+ }
+}
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 2167ff8df9..dd6117e962 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
@@ -103,6 +103,9 @@ HopGui.Menu.File.Save=&Save
HopGui.Menu.File.SaveAs=Save &as...
HopGui.Menu.Help=&Help
HopGui.Menu.Help.About=About...
+HopGui.Menu.View=&View
+HopGui.Menu.View.Terminal=&Terminal
+HopGui.Menu.View.NewTerminal=&New Terminal
HopGui.Menu.Redo.Available=Redo \: {0}
HopGui.Menu.Redo.NotAvailable=Redo \: not available
HopGui.Menu.Run=&Run
diff --git a/ui/src/main/resources/ui/images/terminal.svg
b/ui/src/main/resources/ui/images/terminal.svg
new file mode 100644
index 0000000000..db4fbf614d
--- /dev/null
+++ b/ui/src/main/resources/ui/images/terminal.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24"
height="24">
+ <!-- Terminal window frame - bright color for dark mode visibility -->
+ <rect x="2" y="3" width="20" height="18" rx="2" fill="none" stroke="#53c3c4"
stroke-width="1.5"/>
+
+ <!-- Terminal prompt symbol (>) -->
+ <polyline points="6,9 9,12 6,15" fill="none" stroke="#53c3c4"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+
+ <!-- Command line cursor -->
+ <line x1="11" y1="11" x2="15" y2="11" stroke="#53c3c4" stroke-width="1.5"
stroke-linecap="round"/>
+
+ <!-- Top bar dots (like macOS terminal) -->
+ <circle cx="5" cy="6" r="0.8" fill="#53c3c4"/>
+ <circle cx="7.5" cy="6" r="0.8" fill="#53c3c4"/>
+ <circle cx="10" cy="6" r="0.8" fill="#53c3c4"/>
+</svg>
+
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
new file mode 100644
index 0000000000..01aab9e362
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.terminal;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for HopGuiTerminalPanel business logic.
+ *
+ * <p>Note: This class tests non-UI logic only. Full integration tests with
SWT UI components
+ * require a display environment and are better suited for integration tests.
+ */
+public class HopGuiTerminalPanelTest {
+
+ @Test
+ public void testExtractShellNameLogic() {
+ // Test the logic for extracting shell names from paths
+ String bashPath = "/bin/bash";
+ assertEquals("bash", bashPath.substring(bashPath.lastIndexOf('/') + 1));
+
+ String zshPath = "/bin/zsh";
+ assertEquals("zsh", zshPath.substring(zshPath.lastIndexOf('/') + 1));
+
+ String fishPath = "/usr/local/bin/fish";
+ assertEquals("fish", fishPath.substring(fishPath.lastIndexOf('/') + 1));
+
+ // Windows paths
+ String powershellPath = "C:\\Windows\\System32\\powershell.exe";
+ assertEquals(
+ "powershell.exe",
+ powershellPath.substring(
+ Math.max(powershellPath.lastIndexOf('\\'),
powershellPath.lastIndexOf('/')) + 1));
+ }
+
+ @Test
+ public void testTerminalHeightPercentBounds() {
+ // Test that terminal height percent validation works correctly
+ assertTrue("10% should be valid", 10 > 0 && 10 < 100);
+ assertTrue("50% should be valid", 50 > 0 && 50 < 100);
+ assertTrue("90% should be valid", 90 > 0 && 90 < 100);
+
+ assertFalse("0% should be invalid", 0 > 0 && 0 < 100);
+ assertFalse("100% should be invalid", 100 > 0 && 100 < 100);
+ assertFalse("-10% should be invalid", -10 > 0 && -10 < 100);
+ }
+
+ @Test
+ public void testLogsWidthPercentBounds() {
+ // Test that logs width percent validation works correctly
+ assertTrue("30% should be valid", 30 > 0 && 30 < 100);
+ assertTrue("50% should be valid", 50 > 0 && 50 < 100);
+ assertTrue("70% should be valid", 70 > 0 && 70 < 100);
+
+ assertFalse("0% should be invalid", 0 > 0 && 0 < 100);
+ assertFalse("100% should be invalid", 100 > 0 && 100 < 100);
+ }
+
+ @Test
+ public void testDefaultConfiguration() {
+ // Test default configuration values
+ int defaultTerminalHeight = 35;
+ int defaultLogsWidth = 50;
+
+ assertEquals("Default terminal height should be 35%", 35,
defaultTerminalHeight);
+ assertEquals("Default logs width should be 50%", 50, defaultLogsWidth);
+
+ assertTrue(
+ "Default terminal height should be valid",
+ defaultTerminalHeight > 0 && defaultTerminalHeight < 100);
+ assertTrue(
+ "Default logs width should be valid", defaultLogsWidth > 0 &&
defaultLogsWidth < 100);
+ }
+
+ @Test
+ public void testTerminalCounterIncrement() {
+ // Test that terminal counter logic works correctly
+ int counter = 1;
+
+ // Simulate creating 5 terminals
+ String[] expectedNames = {"(1)", "(2)", "(3)", "(4)", "(5)"};
+ for (String expected : expectedNames) {
+ assertEquals(expected, "(" + counter++ + ")");
+ }
+
+ // Counter should now be 6
+ assertEquals(6, counter);
+ }
+
+ @Test
+ public void testVisibilityStateTransitions() {
+ // Test visibility state logic
+ boolean terminalVisible = false;
+ boolean logsVisible = false;
+
+ // Initial state: both hidden
+ assertFalse("Initial: terminal should be hidden", terminalVisible);
+ assertFalse("Initial: logs should be hidden", logsVisible);
+
+ // Show terminal
+ terminalVisible = true;
+ assertTrue("Terminal should be visible", terminalVisible);
+ assertFalse("Logs should still be hidden", logsVisible);
+
+ // Show logs
+ logsVisible = true;
+ assertTrue("Terminal should still be visible", terminalVisible);
+ assertTrue("Logs should be visible", logsVisible);
+
+ // Hide terminal
+ terminalVisible = false;
+ assertFalse("Terminal should be hidden", terminalVisible);
+ assertTrue("Logs should still be visible", logsVisible);
+
+ // Hide logs
+ logsVisible = false;
+ assertFalse("Terminal should be hidden", terminalVisible);
+ assertFalse("Logs should be hidden", logsVisible);
+ }
+
+ @Test
+ public void testBottomPanelLayoutStates() {
+ // Test the four possible layout states for the bottom panel
+ boolean terminalVisible = false;
+ boolean logsVisible = false;
+
+ // State 1: Both hidden - bottom panel should be hidden
+ assertFalse("State 1: Both should be hidden", terminalVisible ||
logsVisible);
+
+ // State 2: Only terminal visible
+ terminalVisible = true;
+ assertTrue("State 2: Terminal visible, logs hidden", terminalVisible &&
!logsVisible);
+
+ // State 3: Only logs visible
+ terminalVisible = false;
+ logsVisible = true;
+ assertTrue("State 3: Logs visible, terminal hidden", !terminalVisible &&
logsVisible);
+
+ // State 4: Both visible - side-by-side
+ terminalVisible = true;
+ assertTrue("State 4: Both visible", terminalVisible && logsVisible);
+ }
+
+ @Test
+ public void testShellPathValidation() {
+ // Test that shell paths are non-null and non-empty
+ String shellPath = TerminalShellDetector.detectDefaultShell();
+
+ assertNotNull("Shell path should not be null", shellPath);
+ assertFalse("Shell path should not be empty", shellPath.isEmpty());
+ assertTrue("Shell path should have at least one character",
shellPath.length() > 0);
+ }
+
+ @Test
+ public void testWorkingDirectoryDefault() {
+ // Test that working directory defaults to user.home
+ String defaultWorkingDir = System.getProperty("user.home");
+
+ assertNotNull("User home should not be null", defaultWorkingDir);
+ assertFalse("User home should not be empty", defaultWorkingDir.isEmpty());
+ }
+}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetectorTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetectorTest.java
new file mode 100644
index 0000000000..5ee787fe8e
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetectorTest.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.terminal;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class TerminalShellDetectorTest {
+
+ @Test
+ public void testDetectDefaultShell() {
+ // Test that we can detect a default shell
+ String shell = TerminalShellDetector.detectDefaultShell();
+
+ assertNotNull("Shell path should not be null", shell);
+ assertFalse("Shell path should not be empty", shell.isEmpty());
+
+ // Verify the shell path contains expected shell names based on OS
+ String os = System.getProperty("os.name").toLowerCase();
+ if (os.contains("win")) {
+ // Windows should return PowerShell or cmd
+ assertTrue(
+ "Windows should detect PowerShell or cmd",
+ shell.contains("powershell") || shell.contains("cmd"));
+ } else if (os.contains("mac") || os.contains("nix") || os.contains("nux"))
{
+ // Unix-like systems should return a shell in /bin
+ assertTrue("Unix-like systems should return a shell in /bin",
shell.startsWith("/bin/"));
+ }
+ }
+
+ @Test
+ public void testDetectDefaultShellReturnsExecutable() {
+ // Test that the detected shell is an actual executable file
+ String shell = TerminalShellDetector.detectDefaultShell();
+
+ assertNotNull("Shell path should not be null", shell);
+
+ // On Windows, PowerShell/cmd detection is based on typical locations
+ // On Unix, we verify the shell exists in /bin
+ String os = System.getProperty("os.name").toLowerCase();
+ if (!os.contains("win")) {
+ // For Unix systems, verify the shell path looks valid
+ assertTrue(
+ "Shell should be in /bin or /usr/bin",
+ shell.startsWith("/bin/") || shell.startsWith("/usr/bin/"));
+ }
+ }
+
+ @Test
+ public void testShellDetectionFallback() {
+ // This test verifies that even if preferred shells aren't found,
+ // we still get a fallback shell
+ String shell = TerminalShellDetector.detectDefaultShell();
+
+ assertNotNull("Should always return a shell, even if fallback", shell);
+ assertFalse("Shell path should not be empty", shell.isEmpty());
+ }
+}