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

bamaer 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 7783ee83a0 Issue #8277 : Harden the Database perspective, also fixes 
#8278 (#8279)
7783ee83a0 is described below

commit 7783ee83a04e8a13cecd44fadf1a96fbfb94c498
Author: Matt Casters <[email protected]>
AuthorDate: Tue Sep 8 11:58:32 2026 +0200

    Issue #8277 : Harden the Database perspective, also fixes #8278 (#8279)
    
    Keep SQL tab memory, save/close, failures, preview LIMIT, result grids,
    and query classification consistent across the perspective, floating
    window, and dock.
---
 .../org/apache/hop/core/database/Database.java     |   4 +
 .../hop/core/database/SqlQueryClassifier.java      |   3 -
 .../org/apache/hop/core/database/DatabaseTest.java |  14 ++
 .../hop/core/database/SqlQueryClassifierTest.java  |  10 ++
 .../hop/databases/generic/GenericDatabaseMeta.java |   8 ++
 .../databases/generic/GenericDatabaseMetaTest.java |  19 +++
 .../hop/ui/core/dialog/RowPreviewSupport.java      | 147 +++++++++++++++++++++
 .../apache/hop/ui/core/dialog/ShowRowsDialog.java  | 106 +--------------
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |   9 ++
 .../ui/hopgui/delegates/FileRefreshHandlerMap.java |  96 ++++++++++++++
 .../ui/hopgui/delegates/HopGuiFileDelegate.java    |   6 +-
 .../delegates/HopGuiFileRefreshDelegate.java       |  52 +++++---
 .../perspective/database/DatabaseOperation.java    |   7 +
 .../database/DatabaseOperationsPanel.java          |  77 ++++++++++-
 .../perspective/database/DatabaseResultsPanel.java |  10 +-
 .../perspective/database/DatabaseSqlEditorTab.java |  56 +++++++-
 .../perspective/database/DatabaseSqlTabMemory.java | 135 ++++++++++++++++++-
 .../perspective/database/DatabaseWorkbench.java    |  62 +++++++--
 .../database/DatabaseWorkbenchDialog.java          |  10 +-
 .../database/DatabaseWorkbenchViews.java           |   9 ++
 .../database/HopGuiDatabaseWorkbenchHost.java      |  10 ++
 .../perspective/explorer/ExplorerPerspective.java  |   6 +-
 .../hop/ui/hopgui/terminal/HopGuiBottomDock.java   |   9 ++
 .../database/messages/messages_en_US.properties    |   2 +
 .../hop/ui/core/dialog/RowPreviewSupportTest.java  |  68 ++++++++++
 .../delegates/FileRefreshHandlerMapTest.java       |  56 ++++++++
 .../database/DatabaseOperationsPanelTest.java      |  31 ++++-
 .../database/DatabaseWorkbenchPreviewSqlTest.java  |  14 ++
 .../database/DatabaseWorkbenchViewsTest.java       |   5 +
 29 files changed, 888 insertions(+), 153 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/database/Database.java 
b/core/src/main/java/org/apache/hop/core/database/Database.java
index 1b25d89990..a66a658906 100644
--- a/core/src/main/java/org/apache/hop/core/database/Database.java
+++ b/core/src/main/java/org/apache/hop/core/database/Database.java
@@ -624,10 +624,12 @@ public class Database implements IVariables, 
ILoggingObject, AutoCloseable {
     openQueryStatement = null;
     openQueryRowMeta = null;
     if (connection == null) {
+      dbmd = null;
       return; // Nothing to do...
     }
     try {
       if (connection.isClosed()) {
+        dbmd = null;
         return; // Nothing to do...
       }
     } catch (SQLException ex) {
@@ -723,6 +725,7 @@ public class Database implements IVariables, 
ILoggingObject, AutoCloseable {
         log.logError(Const.getStackTracker(hde));
       }
       statementQueryTimeoutSeconds = 0;
+      dbmd = null;
     }
   }
 
@@ -738,6 +741,7 @@ public class Database implements IVariables, 
ILoggingObject, AutoCloseable {
         connection.close();
         connection = null;
       }
+      dbmd = null;
 
       if (log.isDetailed()) {
         log.logDetailed("Connection to database closed!");
diff --git 
a/core/src/main/java/org/apache/hop/core/database/SqlQueryClassifier.java 
b/core/src/main/java/org/apache/hop/core/database/SqlQueryClassifier.java
index 3349daf093..30039f6e47 100644
--- a/core/src/main/java/org/apache/hop/core/database/SqlQueryClassifier.java
+++ b/core/src/main/java/org/apache/hop/core/database/SqlQueryClassifier.java
@@ -220,9 +220,6 @@ public final class SqlQueryClassifier {
         if ("INTO".equals(keyword)) {
           return true;
         }
-        if ("FROM".equals(keyword) || "WHERE".equals(keyword)) {
-          return false;
-        }
         if (keyword != null) {
           i = skipKeyword(sql, i);
           continue;
diff --git a/core/src/test/java/org/apache/hop/core/database/DatabaseTest.java 
b/core/src/test/java/org/apache/hop/core/database/DatabaseTest.java
index 2fcb33c1fe..5f2dd67a60 100644
--- a/core/src/test/java/org/apache/hop/core/database/DatabaseTest.java
+++ b/core/src/test/java/org/apache/hop/core/database/DatabaseTest.java
@@ -522,6 +522,20 @@ class DatabaseTest {
     verify(conn, never()).close();
   }
 
+  @Test
+  void disconnectClearsCachedDatabaseMetaData() throws Exception {
+    Database db = new Database(log, variables, meta);
+    Connection connection = mockConnection(dbMetaData);
+    db.setConnection(connection);
+    assertNotNull(db.getDatabaseMetaData());
+
+    db.disconnect();
+
+    Field field = Database.class.getDeclaredField("dbmd");
+    field.setAccessible(true);
+    assertNull(field.get(db));
+  }
+
   @Test
   void testGetTablenames() throws SQLException, HopDatabaseException {
     when(rs.next()).thenReturn(true, false);
diff --git 
a/core/src/test/java/org/apache/hop/core/database/SqlQueryClassifierTest.java 
b/core/src/test/java/org/apache/hop/core/database/SqlQueryClassifierTest.java
index d976017e1b..d6fe647bcb 100644
--- 
a/core/src/test/java/org/apache/hop/core/database/SqlQueryClassifierTest.java
+++ 
b/core/src/test/java/org/apache/hop/core/database/SqlQueryClassifierTest.java
@@ -46,6 +46,16 @@ class SqlQueryClassifierTest {
   @Test
   void selectIntoIsNotAQuery() {
     assertFalse(SqlQueryClassifier.isQuery("SELECT * INTO dest FROM src"));
+    assertFalse(SqlQueryClassifier.isQuery("SELECT id FROM customers INTO 
@last_id"));
+    assertFalse(
+        SqlQueryClassifier.isQuery("SELECT id, name FROM customers INTO 
OUTFILE '/tmp/c.csv'"));
+    assertFalse(SqlQueryClassifier.isQuery("WITH s AS (SELECT 1) SELECT * FROM 
s INTO @x"));
+  }
+
+  @Test
+  void intoInsideAStringOrSubqueryDoesNotHideASelect() {
+    assertTrue(SqlQueryClassifier.isQuery("SELECT * FROM t WHERE note = 
'insert into x'"));
+    assertTrue(SqlQueryClassifier.isQuery("SELECT * FROM (SELECT * INTO dest 
FROM src) s"));
   }
 
   @Test
diff --git 
a/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java
 
b/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java
index a78a3bfb14..bf3b835992 100644
--- 
a/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java
+++ 
b/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java
@@ -478,6 +478,14 @@ public class GenericDatabaseMeta extends BaseDatabaseMeta 
implements IDatabase {
     return super.getLimitClause(nrRows);
   }
 
+  @Override
+  public String getLimitClausePrefix(int nrRows) {
+    if (databaseDialect != null) {
+      return databaseDialect.getLimitClausePrefix(nrRows);
+    }
+    return super.getLimitClausePrefix(nrRows);
+  }
+
   @Override
   public String getSelectCountStatement(String tableName) {
     if (databaseDialect != null) {
diff --git 
a/plugins/databases/generic/src/test/java/org/apache/hop/databases/generic/GenericDatabaseMetaTest.java
 
b/plugins/databases/generic/src/test/java/org/apache/hop/databases/generic/GenericDatabaseMetaTest.java
index 14f1ba316e..7b5d872541 100644
--- 
a/plugins/databases/generic/src/test/java/org/apache/hop/databases/generic/GenericDatabaseMetaTest.java
+++ 
b/plugins/databases/generic/src/test/java/org/apache/hop/databases/generic/GenericDatabaseMetaTest.java
@@ -230,6 +230,25 @@ class GenericDatabaseMetaTest {
         nativeMeta.getSqlInsertAutoIncUnknownDimensionRow("FOO", "FOOKEY", 
"FOOVERSION"));
   }
 
+  @Test
+  void limitClausePrefixDelegatesToDialect() {
+    String dialect = "mssql";
+    IDatabase dialectMeta = Mockito.mock(IDatabase.class);
+    Mockito.when(dialectMeta.getPluginName()).thenReturn(dialect);
+    Mockito.when(dialectMeta.getLimitClausePrefix(25)).thenReturn(" TOP 25");
+    IDatabase[] dbInterfaces = new IDatabase[] {dialectMeta};
+    try (MockedStatic<DatabaseMeta> dbMetaStatic = 
Mockito.mockStatic(DatabaseMeta.class)) {
+      
dbMetaStatic.when(DatabaseMeta::getDatabaseInterfaces).thenReturn(dbInterfaces);
+      nativeMeta.setDatabaseDialect(dialect);
+      assertEquals(" TOP 25", nativeMeta.getLimitClausePrefix(25));
+    }
+  }
+
+  @Test
+  void limitClausePrefixWithoutDialectIsEmpty() {
+    assertEquals("", nativeMeta.getLimitClausePrefix(10));
+  }
+
   @Test
   void testSettingDialect() {
     String dialect = "testDialect";
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/RowPreviewSupport.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/RowPreviewSupport.java
new file mode 100644
index 0000000000..19f9e64a9e
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/RowPreviewSupport.java
@@ -0,0 +1,147 @@
+/*
+ * 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.core.dialog;
+
+import java.util.Objects;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.widgets.TableItem;
+
+/** Shared row-preview formatting used by {@link ShowRowsDialog} and the 
Database results panel. */
+public final class RowPreviewSupport {
+
+  private static final Class<?> PKG = ShowRowsDialog.class;
+
+  private RowPreviewSupport() {}
+
+  public static boolean avoidBinaryHexPreview() {
+    return Const.toBoolean(
+        
HopConfig.readStringVariable(Const.HOP_BINARY_FIELDS_AVOID_HEX_PREVIEW, 
"false"));
+  }
+
+  public static String formatCell(IValueMeta valueMeta, Object value) throws 
HopValueException {
+    return formatCell(valueMeta, value, avoidBinaryHexPreview());
+  }
+
+  static String formatCell(IValueMeta valueMeta, Object value, boolean 
avoidHex)
+      throws HopValueException {
+    if (valueMeta == null) {
+      return null;
+    }
+    if (valueMeta.isBinary()) {
+      byte[] bytes = valueMeta.getBinary(value);
+      if (bytes == null) {
+        return null;
+      }
+      String display = avoidHex ? valueMeta.getString(bytes) : 
Hex.encodeHexString(bytes);
+      if (display != null && display.length() > 
PreviewRowsDialog.MAX_BINARY_STRING_PREVIEW_SIZE) {
+        return display.substring(0, 
PreviewRowsDialog.MAX_BINARY_STRING_PREVIEW_SIZE);
+      }
+      return display;
+    }
+    return valueMeta.getString(value);
+  }
+
+  public static String formatColumnMetaTooltip(IValueMeta valueMeta) {
+    if (valueMeta == null) {
+      return null;
+    }
+    StringBuilder tip = new StringBuilder();
+    tip.append(
+        BaseMessages.getString(
+            PKG, "ShowRowsDialog.CellTooltip.Name", 
Const.NVL(valueMeta.getName(), "")));
+    tip.append(Const.CR);
+    tip.append(
+        BaseMessages.getString(
+            PKG, "ShowRowsDialog.CellTooltip.Type", 
Const.NVL(valueMeta.getTypeDesc(), "")));
+    if (valueMeta.getLength() > 0) {
+      tip.append(Const.CR);
+      tip.append(
+          BaseMessages.getString(
+              PKG, "ShowRowsDialog.CellTooltip.Length", 
Integer.toString(valueMeta.getLength())));
+    }
+    if (valueMeta.getPrecision() > 0) {
+      tip.append(Const.CR);
+      tip.append(
+          BaseMessages.getString(
+              PKG,
+              "ShowRowsDialog.CellTooltip.Precision",
+              Integer.toString(valueMeta.getPrecision())));
+    }
+    if (!Utils.isEmpty(valueMeta.getOrigin())) {
+      tip.append(Const.CR);
+      tip.append(
+          BaseMessages.getString(PKG, "ShowRowsDialog.CellTooltip.Origin", 
valueMeta.getOrigin()));
+    }
+    return tip.toString();
+  }
+
+  public static void applyColumnMeta(ColumnInfo column, IValueMeta valueMeta) {
+    if (column == null || valueMeta == null) {
+      return;
+    }
+    column.setToolTip(formatColumnMetaTooltip(valueMeta));
+    column.setValueMeta(valueMeta);
+    column.setImage(GuiResource.getInstance().getImage(valueMeta));
+    column.setReadOnly(true);
+  }
+
+  public static void installCellTooltips(TableView tableView, IRowMeta 
rowMeta) {
+    if (tableView == null || tableView.isDisposed() || rowMeta == null) {
+      return;
+    }
+    tableView.table.addListener(
+        SWT.MouseMove,
+        event -> {
+          int dataColumn = dataColumnAt(tableView, rowMeta, new Point(event.x, 
event.y));
+          String tip =
+              dataColumn < 0 ? null : 
formatColumnMetaTooltip(rowMeta.getValueMeta(dataColumn));
+          if (!Objects.equals(tip, tableView.table.getToolTipText())) {
+            tableView.table.setToolTipText(tip);
+          }
+        });
+  }
+
+  static int dataColumnAt(TableView tableView, IRowMeta rowMeta, Point point) {
+    if (tableView == null || tableView.isDisposed() || rowMeta == null || 
point == null) {
+      return -1;
+    }
+    TableItem item = tableView.table.getItem(point);
+    if (item == null) {
+      return -1;
+    }
+    for (int i = 1; i < tableView.table.getColumnCount(); i++) {
+      if (item.getBounds(i).contains(point)) {
+        int dataColumn = i - 1;
+        return dataColumn < rowMeta.size() ? dataColumn : -1;
+      }
+    }
+    return -1;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
index b0a80ff4ac..c3661f0e4d 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
@@ -18,8 +18,6 @@
 package org.apache.hop.ui.core.dialog;
 
 import java.util.List;
-import java.util.Objects;
-import org.apache.commons.codec.binary.Hex;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.exception.HopValueException;
@@ -35,7 +33,6 @@ import org.apache.hop.ui.core.gui.WindowProperty;
 import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.TableView;
 import org.eclipse.swt.SWT;
-import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
@@ -58,9 +55,6 @@ public final class ShowRowsDialog {
 
   private static final Class<?> PKG = ShowRowsDialog.class;
 
-  private static final int MAX_BINARY_STRING_PREVIEW_SIZE =
-      PreviewRowsDialog.MAX_BINARY_STRING_PREVIEW_SIZE;
-
   private static final boolean AVOID_BINARY_IN_HEX =
       Const.toBoolean(
           
HopConfig.readStringVariable(Const.HOP_BINARY_FIELDS_AVOID_HEX_PREVIEW, 
"false"));
@@ -131,7 +125,7 @@ public final class ShowRowsDialog {
 
     tableView = buildTableView(margin, messageLabel);
     populateRows();
-    setupCellTooltip();
+    RowPreviewSupport.installCellTooltips(tableView, rowMeta);
 
     BaseDialog.defaultShellHandling(shell, c -> close(), c -> close());
   }
@@ -142,12 +136,7 @@ public final class ShowRowsDialog {
       IValueMeta valueMeta = rowMeta.getValueMeta(i);
       columns[i] =
           new ColumnInfo(valueMeta.getName(), ColumnInfo.COLUMN_TYPE_TEXT, 
valueMeta.isNumeric());
-      columns[i].setToolTip(formatColumnMetaTooltip(valueMeta));
-      columns[i].setValueMeta(valueMeta);
-      columns[i].setImage(GuiResource.getInstance().getImage(valueMeta));
-      // Read-only: the grid gives such a column a view-only inline editor 
with an expand icon, so
-      // the full value stays selectable in place and can be opened in the 
multi-line viewer.
-      columns[i].setReadOnly(true);
+      RowPreviewSupport.applyColumnMeta(columns[i], valueMeta);
     }
 
     TableView view =
@@ -208,20 +197,7 @@ public final class ShowRowsDialog {
       IValueMeta valueMeta = rowMeta.getValueMeta(column);
       String displayValue;
       try {
-        if (valueMeta.isBinary()) {
-          byte[] bytes = valueMeta.getBinary(row[column]);
-          if (bytes == null) {
-            displayValue = null;
-          } else {
-            displayValue =
-                AVOID_BINARY_IN_HEX ? valueMeta.getString(bytes) : 
Hex.encodeHexString(bytes);
-            if (displayValue != null && displayValue.length() > 
MAX_BINARY_STRING_PREVIEW_SIZE) {
-              displayValue = displayValue.substring(0, 
MAX_BINARY_STRING_PREVIEW_SIZE);
-            }
-          }
-        } else {
-          displayValue = valueMeta.getString(row[column]);
-        }
+        displayValue = RowPreviewSupport.formatCell(valueMeta, row[column], 
AVOID_BINARY_IN_HEX);
       } catch (HopValueException | ArrayIndexOutOfBoundsException e) {
         new LogChannel(PKG).logError("Unable to format cell value", e);
         displayValue = null;
@@ -247,81 +223,7 @@ public final class ShowRowsDialog {
     shell.dispose();
   }
 
-  /**
-   * Hovering a cell shows column metadata in a tooltip: name, type, length, 
precision. Selecting a
-   * cell is handled by the grid itself: a read-only column gets a view-only 
inline editor holding
-   * the full value, with an expand icon for the multi-line viewer.
-   */
-  private void setupCellTooltip() {
-    tableView.table.addListener(
-        SWT.MouseMove,
-        event -> {
-          int dataColumn = dataColumnAt(new Point(event.x, event.y));
-          String tip =
-              dataColumn < 0 ? null : 
formatColumnMetaTooltip(rowMeta.getValueMeta(dataColumn));
-          if (!Objects.equals(tip, tableView.table.getToolTipText())) {
-            tableView.table.setToolTipText(tip);
-          }
-        });
-  }
-
-  /**
-   * Build a multi-line tooltip describing a column: name, type, and optional 
length / precision /
-   * origin.
-   */
   static String formatColumnMetaTooltip(IValueMeta valueMeta) {
-    if (valueMeta == null) {
-      return null;
-    }
-    StringBuilder tip = new StringBuilder();
-    tip.append(
-        BaseMessages.getString(
-            PKG, "ShowRowsDialog.CellTooltip.Name", 
Const.NVL(valueMeta.getName(), "")));
-    tip.append(Const.CR);
-    tip.append(
-        BaseMessages.getString(
-            PKG, "ShowRowsDialog.CellTooltip.Type", 
Const.NVL(valueMeta.getTypeDesc(), "")));
-    if (valueMeta.getLength() > 0) {
-      tip.append(Const.CR);
-      tip.append(
-          BaseMessages.getString(
-              PKG, "ShowRowsDialog.CellTooltip.Length", 
Integer.toString(valueMeta.getLength())));
-    }
-    if (valueMeta.getPrecision() > 0) {
-      tip.append(Const.CR);
-      tip.append(
-          BaseMessages.getString(
-              PKG,
-              "ShowRowsDialog.CellTooltip.Precision",
-              Integer.toString(valueMeta.getPrecision())));
-    }
-    if (!Utils.isEmpty(valueMeta.getOrigin())) {
-      tip.append(Const.CR);
-      tip.append(
-          BaseMessages.getString(PKG, "ShowRowsDialog.CellTooltip.Origin", 
valueMeta.getOrigin()));
-    }
-    return tip.toString();
-  }
-
-  /**
-   * The 0-based data column under a table-relative point, or -1 when the 
point isn't over a data
-   * cell (the row-number column, empty space, or a column outside the row 
metadata).
-   */
-  private int dataColumnAt(Point point) {
-    if (tableView == null || tableView.isDisposed() || rowMeta == null) {
-      return -1;
-    }
-    TableItem item = tableView.table.getItem(point);
-    if (item == null) {
-      return -1;
-    }
-    // Column 0 is the row-number column; data columns start at 1. Find the 
one under the pointer.
-    for (int i = 1; i < tableView.table.getColumnCount(); i++) {
-      if (item.getBounds(i).contains(point)) {
-        int dataColumn = i - 1;
-        return dataColumn < rowMeta.size() ? dataColumn : -1;
-      }
-    }
-    return -1;
+    return RowPreviewSupport.formatColumnMetaTooltip(valueMeta);
   }
 }
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 88bf432e52..3605a396c2 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
@@ -134,6 +134,7 @@ import 
org.apache.hop.ui.hopgui.perspective.HopPerspectivePlugin;
 import org.apache.hop.ui.hopgui.perspective.HopPerspectivePluginType;
 import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
 import 
org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseSqlEditorTab;
 import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import org.apache.hop.ui.hopgui.perspective.metadata.MetadataPerspective;
@@ -351,6 +352,7 @@ public class HopGui
   private Composite mainPerspectivesComposite;
   private HopPerspectiveManager perspectiveManager;
   private IHopPerspective activePerspective;
+  private IHopFileTypeHandler capabilityFileTypeHandler;
   private org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock terminalPanel;
 
   public org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock getTerminalPanel() 
{
@@ -2323,6 +2325,8 @@ public class HopGui
       boolean running,
       boolean paused) {
 
+    this.capabilityFileTypeHandler = handler;
+
     mainMenuWidgets.enableMenuItem(
         fileType, handler, ID_MAIN_MENU_FILE_SAVE, 
IHopFileType.CAPABILITY_SAVE, changed);
     mainMenuWidgets.enableMenuItem(
@@ -2389,6 +2393,11 @@ public class HopGui
   }
 
   public IHopFileTypeHandler getActiveFileTypeHandler() {
+    if (capabilityFileTypeHandler instanceof DatabaseSqlEditorTab tab
+        && tab.getControl() != null
+        && !tab.getControl().isDisposed()) {
+      return tab;
+    }
     return getActivePerspective().getActiveFileTypeHandler();
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/FileRefreshHandlerMap.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/FileRefreshHandlerMap.java
new file mode 100644
index 0000000000..fb6376d80c
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/FileRefreshHandlerMap.java
@@ -0,0 +1,96 @@
+/*
+ * 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.delegates;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+
+/** Path → handlers. Several editors can watch the same file; the last close 
unwatches. */
+final class FileRefreshHandlerMap {
+
+  private final Map<String, List<IHopFileTypeHandler>> handlers = new 
HashMap<>();
+
+  /**
+   * @return {@code true} when this is the first handler for {@code key}
+   */
+  boolean add(String key, IHopFileTypeHandler handler) {
+    if (key == null || handler == null) {
+      return false;
+    }
+    List<IHopFileTypeHandler> list = handlers.computeIfAbsent(key, k -> new 
ArrayList<>());
+    if (list.contains(handler)) {
+      return false;
+    }
+    list.add(handler);
+    return list.size() == 1;
+  }
+
+  /** Make {@code aliasKey} share the handler list of {@code existingKey}. */
+  void alias(String existingKey, String aliasKey) {
+    if (existingKey == null || aliasKey == null || 
existingKey.equals(aliasKey)) {
+      return;
+    }
+    List<IHopFileTypeHandler> list = handlers.get(existingKey);
+    if (list != null) {
+      handlers.put(aliasKey, list);
+    }
+  }
+
+  List<IHopFileTypeHandler> get(String key) {
+    List<IHopFileTypeHandler> list = handlers.get(key);
+    return list == null ? List.of() : List.copyOf(list);
+  }
+
+  /**
+   * @return {@code true} when no handlers remain for {@code key} (the watch 
can be dropped)
+   */
+  boolean remove(String key, IHopFileTypeHandler handler) {
+    if (key == null) {
+      return true;
+    }
+    List<IHopFileTypeHandler> list = handlers.get(key);
+    if (list == null) {
+      return true;
+    }
+    list.remove(handler);
+    if (list.isEmpty()) {
+      removeKeysForList(list);
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * @return {@code true} when the path is no longer watched
+   */
+  boolean removeAll(String key) {
+    List<IHopFileTypeHandler> list = handlers.get(key);
+    if (list == null) {
+      return true;
+    }
+    removeKeysForList(list);
+    return true;
+  }
+
+  private void removeKeysForList(List<IHopFileTypeHandler> list) {
+    handlers.entrySet().removeIf(entry -> entry.getValue() == list);
+  }
+}
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 154e8236fb..0fc981b990 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
@@ -59,6 +59,7 @@ import 
org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
 import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
 import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
 import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseSqlEditorTab;
 import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import org.apache.hop.ui.util.EnvironmentUtils;
@@ -271,7 +272,10 @@ public class HopGuiFileDelegate {
       IHopFileTypeHandler typeHandler = getActiveFileTypeHandler();
       IHopFileType fileType = typeHandler.getFileType();
       if (fileType.hasCapability(IHopFileType.CAPABILITY_CLOSE)) {
-        boolean removed = perspective.remove(typeHandler);
+        boolean removed =
+            typeHandler instanceof DatabaseSqlEditorTab sqlTab
+                ? sqlTab.requestClose()
+                : perspective.remove(typeHandler);
         if (removed) {
           hopGui.auditDelegate.writeLastOpenFiles();
         }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileRefreshDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileRefreshDelegate.java
index ee916d5ea5..4e1c22d3ea 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileRefreshDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileRefreshDelegate.java
@@ -17,8 +17,6 @@
 
 package org.apache.hop.ui.hopgui.delegates;
 
-import java.util.HashMap;
-import java.util.Map;
 import org.apache.commons.vfs2.FileChangeEvent;
 import org.apache.commons.vfs2.FileListener;
 import org.apache.commons.vfs2.FileObject;
@@ -34,17 +32,14 @@ public class HopGuiFileRefreshDelegate {
 
   private DefaultFileMonitor fileMonitor;
 
-  // key: vfs file uri
-  // value: the corresponding fileTypeHandler
-  //
-  private Map<String, IHopFileTypeHandler> fileHandlerMap;
+  private final FileRefreshHandlerMap fileHandlerMap;
 
   // TODO: replace it with a config option
   private static final long DELAY = 1000l;
 
   public HopGuiFileRefreshDelegate(HopGui hopGui) {
     this.hopGui = hopGui;
-    this.fileHandlerMap = new HashMap<>();
+    this.fileHandlerMap = new FileRefreshHandlerMap();
     this.fileMonitor =
         new DefaultFileMonitor(
             new FileListener() {
@@ -52,9 +47,8 @@ public class HopGuiFileRefreshDelegate {
               @Override
               public void fileChanged(FileChangeEvent arg0) throws Exception {
                 String fileName = arg0.getFileObject().getName().getURI();
-                if (fileName != null) {
-                  IHopFileTypeHandler fileHandler = 
fileHandlerMap.get(fileName);
-                  if (fileHandler != null && 
!hopGui.getDisplay().isDisposed()) {
+                if (fileName != null && !hopGui.getDisplay().isDisposed()) {
+                  for (IHopFileTypeHandler fileHandler : 
fileHandlerMap.get(fileName)) {
                     hopGui.getDisplay().asyncExec(fileHandler::reload);
                   }
                 }
@@ -86,26 +80,48 @@ public class HopGuiFileRefreshDelegate {
 
     try {
       FileObject file = HopVfs.getFileObject(fileName);
-      fileMonitor.addFile(file);
-      fileHandlerMap.put(file.getPublicURIString(), fileTypeHandler);
+      String uri = file.getPublicURIString();
+      boolean first = fileHandlerMap.add(uri, fileTypeHandler);
+      fileHandlerMap.alias(uri, fileName);
+      if (first) {
+        fileMonitor.addFile(file);
+      }
     } catch (HopFileException e) {
       hopGui.getLog().logError("Error registering new FileObject", e);
+      fileHandlerMap.add(fileName, fileTypeHandler);
     }
-    fileHandlerMap.put(fileName, fileTypeHandler);
   }
 
   public void remove(String fileName) {
-    if (!hopGui.getProps().isReloadingFilesOnChange()) {
+    remove(fileName, null);
+  }
+
+  public void remove(String fileName, IHopFileTypeHandler fileTypeHandler) {
+    if (fileName == null || !hopGui.getProps().isReloadingFilesOnChange()) {
       return;
     }
     try {
       FileObject file = HopVfs.getFileObject(fileName);
-      fileName = file.getPublicURIString();
-      fileMonitor.removeFile(file);
+      String uri = file.getPublicURIString();
+      boolean empty =
+          fileTypeHandler == null
+              ? fileHandlerMap.removeAll(uri)
+              : fileHandlerMap.remove(uri, fileTypeHandler);
+      if (fileTypeHandler != null) {
+        fileHandlerMap.remove(fileName, fileTypeHandler);
+      } else {
+        fileHandlerMap.removeAll(fileName);
+      }
+      if (empty) {
+        fileMonitor.removeFile(file);
+      }
     } catch (HopFileException e) {
       hopGui.getLog().logError("Error removing FileObject from fileListener", 
e);
-    } finally {
-      fileHandlerMap.remove(fileName);
+      if (fileTypeHandler == null) {
+        fileHandlerMap.removeAll(fileName);
+      } else {
+        fileHandlerMap.remove(fileName, fileTypeHandler);
+      }
     }
   }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
index 870e4bef01..9ce401ba12 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
@@ -74,6 +74,7 @@ public class DatabaseOperation {
         // Best-effort; the worker still sees isCancelled().
       }
     }
+    detachDatabase();
   }
 
   public boolean isCancelled() {
@@ -87,12 +88,18 @@ public class DatabaseOperation {
   public void complete() {
     status = cancelled ? Status.CANCELLED : Status.DONE;
     endTime = System.currentTimeMillis();
+    detachDatabase();
   }
 
   public void fail(String message) {
     status = cancelled ? Status.CANCELLED : Status.FAILED;
     errorMessage = message;
     endTime = System.currentTimeMillis();
+    detachDatabase();
+  }
+
+  void detachDatabase() {
+    database.set(null);
   }
 
   public long elapsedMillis() {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanel.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanel.java
index 024d8af2e9..5f9e2eed67 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanel.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanel.java
@@ -20,6 +20,7 @@ package org.apache.hop.ui.hopgui.perspective.database;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.function.Consumer;
 import lombok.Getter;
 import org.apache.hop.core.Const;
@@ -33,6 +34,7 @@ import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
@@ -55,8 +57,11 @@ public class DatabaseOperationsPanel extends Composite {
 
   public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = 
"DatabaseOperationsPanel-Toolbar";
   public static final String TOOLBAR_ITEM_KILL = 
"DatabaseOperations-Toolbar-10000-Kill";
+  public static final String TOOLBAR_ITEM_CLEAR = 
"DatabaseOperations-Toolbar-10005-Clear";
   public static final String TOOLBAR_ITEM_MINIMIZE = 
"DatabaseOperations-Toolbar-10010-Minimize";
 
+  static final int MAX_FINISHED_OPERATIONS = 50;
+
   public static final String GUI_PLUGIN_STATUS_TOOLBAR_PARENT_ID =
       "DatabaseOperationsStatus-Toolbar";
   public static final String TOOLBAR_ITEM_STATUS_KILL =
@@ -97,6 +102,19 @@ public class DatabaseOperationsPanel extends Composite {
     table.setLayoutData(
         new FormDataBuilder().top(toolBar, 
PropsUi.getMargin()).bottom().fullWidth().result());
     table.addListener(SWT.Selection, e -> updateKillEnablement());
+    table.addListener(
+        SWT.MouseMove,
+        e -> {
+          TableItem item = table.getItem(new Point(e.x, e.y));
+          String tip = "";
+          if (item != null && item.getData() instanceof DatabaseOperation 
operation) {
+            tip =
+                Const.NVL(operation.getErrorMessage(), 
Const.NVL(formatStatusLine(operation), ""));
+          }
+          if (!Objects.equals(tip, table.getToolTipText())) {
+            table.setToolTipText(tip);
+          }
+        });
 
     addColumn(
         BaseMessages.getString(PKG, 
"DatabasePerspective.Operations.Column.Description"), 280);
@@ -159,8 +177,8 @@ public class DatabaseOperationsPanel extends Composite {
 
   public void addOperation(DatabaseOperation operation) {
     operations.add(0, operation);
-    TableItem item = new TableItem(table, SWT.NONE, 0);
-    fillItem(item, operation);
+    trimFinished(operations, MAX_FINISHED_OPERATIONS);
+    rebuildTable();
     table.setSelection(0);
     updateStatusLine();
     updateKillEnablement();
@@ -179,6 +197,48 @@ public class DatabaseOperationsPanel extends Composite {
     armTimer();
   }
 
+  public void clearFinished() {
+    operations.removeIf(DatabaseOperation::isFinished);
+    rebuildTable();
+    updateStatusLine();
+    updateKillEnablement();
+  }
+
+  public void clearAll() {
+    operations.clear();
+    rebuildTable();
+    updateStatusLine();
+    updateKillEnablement();
+  }
+
+  private void rebuildTable() {
+    if (table == null || table.isDisposed()) {
+      return;
+    }
+    table.removeAll();
+    for (DatabaseOperation operation : operations) {
+      TableItem item = new TableItem(table, SWT.NONE);
+      fillItem(item, operation);
+    }
+  }
+
+  static void trimFinished(List<DatabaseOperation> operations, int 
maxFinished) {
+    if (operations == null || maxFinished < 0) {
+      return;
+    }
+    int finished = 0;
+    for (int i = 0; i < operations.size(); i++) {
+      if (!operations.get(i).isFinished()) {
+        continue;
+      }
+      finished++;
+      if (finished > maxFinished) {
+        operations.remove(i);
+        i--;
+      }
+    }
+  }
+
   private void refreshElapsed() {
     timerArmed = false;
     if (isDisposed()) {
@@ -258,6 +318,10 @@ public class DatabaseOperationsPanel extends Composite {
     }
     line.append(" - ").append(statusLabel(operation));
     line.append(" - ").append(formatElapsed(operation.elapsedMillis()));
+    if (operation.getStatus() == DatabaseOperation.Status.FAILED
+        && !Utils.isEmpty(operation.getErrorMessage())) {
+      line.append(" - ").append(operation.getErrorMessage());
+    }
     return line.toString();
   }
 
@@ -285,6 +349,15 @@ public class DatabaseOperationsPanel extends Composite {
     }
   }
 
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_CLEAR,
+      toolTip = "i18n::DatabasePerspective.Operations.Clear.Tooltip",
+      image = "ui/images/clear.svg")
+  public void clearFinishedFromToolbar() {
+    clearFinished();
+  }
+
   @GuiToolbarElement(
       root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
       id = TOOLBAR_ITEM_MINIMIZE,
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseResultsPanel.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseResultsPanel.java
index e959df55df..6438d309ec 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseResultsPanel.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseResultsPanel.java
@@ -27,6 +27,7 @@ import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.RowPreviewSupport;
 import org.apache.hop.ui.core.dialog.ShowRowsDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.ColumnInfo;
@@ -187,9 +188,7 @@ public class DatabaseResultsPanel extends Composite {
       IValueMeta valueMeta = rowMeta.getValueMeta(i);
       columnInfos[i] =
           new ColumnInfo(valueMeta.getName(), ColumnInfo.COLUMN_TYPE_TEXT, 
valueMeta.isNumeric());
-      columnInfos[i].setValueMeta(valueMeta);
-      columnInfos[i].setReadOnly(true);
-      columnInfos[i].setImage(GuiResource.getInstance().getImage(valueMeta));
+      RowPreviewSupport.applyColumnMeta(columnInfos[i], valueMeta);
     }
 
     TableView view =
@@ -206,6 +205,9 @@ public class DatabaseResultsPanel extends Composite {
     view.setShortenDisplayedValues(true);
     view.setSortable(true);
     view.setReadonly(true);
+    if (rowMeta != null) {
+      RowPreviewSupport.installCellTooltips(view, rowMeta);
+    }
 
     if (result.rows != null && rowMeta != null) {
       int lineNr = 0;
@@ -220,7 +222,7 @@ public class DatabaseResultsPanel extends Composite {
         for (int c = 0; c < rowMeta.size(); c++) {
           String display;
           try {
-            display = rowMeta.getValueMeta(c).getString(row[c]);
+            display = RowPreviewSupport.formatCell(rowMeta.getValueMeta(c), 
row[c]);
           } catch (HopValueException | ArrayIndexOutOfBoundsException e) {
             display = null;
           }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
index 48724e1366..34168870fa 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
@@ -38,6 +38,7 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementFilter;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.util.EnvUtil;
 import org.apache.hop.core.util.Utils;
@@ -46,6 +47,7 @@ import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.gui.GuiResource;
@@ -429,13 +431,26 @@ public class DatabaseSqlEditorTab implements 
IHopFileTypeHandler {
                     .append(Const.CR);
               }
             }
+          } catch (Exception e) {
+            if (!messages.isEmpty() && messages.charAt(messages.length() - 1) 
!= '\n') {
+              messages.append(Const.CR);
+            }
+            messages
+                .append(BaseMessages.getString(PKG, 
"DatabasePerspective.SqlTab.Failed"))
+                .append(": ")
+                .append(Const.NVL(e.getMessage(), e.toString()));
+            throw e;
+          } finally {
+            String messageText = messages.toString();
+            host.asyncExec(
+                () -> {
+                  if (control.isDisposed() || resultsPanel.isDisposed()) {
+                    return;
+                  }
+                  resultsPanel.show(queryResults, messageText);
+                  showResults();
+                });
           }
-          String messageText = messages.toString();
-          host.asyncExec(
-              () -> {
-                resultsPanel.show(queryResults, messageText);
-                showResults();
-              });
         });
   }
 
@@ -633,6 +648,19 @@ public class DatabaseSqlEditorTab implements 
IHopFileTypeHandler {
     }
   }
 
+  @Override
+  public void reload() {
+    if (Utils.isEmpty(filename) || changed) {
+      return;
+    }
+    try {
+      loadFromVfs();
+      host.updateGui(this);
+    } catch (Exception e) {
+      LogChannel.UI.logError("Unable to reload SQL file '" + filename + "'", 
e);
+    }
+  }
+
   @Override
   public void start() {}
 
@@ -701,7 +729,17 @@ public class DatabaseSqlEditorTab implements 
IHopFileTypeHandler {
       int answer = messageDialog.open();
       if ((answer & SWT.YES) != 0) {
         if (Utils.isEmpty(filename)) {
-          host.getHopGui().fileDelegate.fileSaveAs();
+          String newFilename =
+              BaseDialog.presentFileDialog(
+                  true,
+                  host.getShell(),
+                  FILE_TYPE.getFilterExtensions(),
+                  FILE_TYPE.getFilterNames(),
+                  true);
+          if (newFilename == null) {
+            return false;
+          }
+          saveAs(getVariables().resolve(newFilename));
           return !changed;
         }
         save();
@@ -723,6 +761,10 @@ public class DatabaseSqlEditorTab implements 
IHopFileTypeHandler {
     workbench.remove(this);
   }
 
+  public boolean requestClose() {
+    return workbench.remove(this);
+  }
+
   @Override
   public boolean hasChanged() {
     return changed;
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
index ab5e050208..b509bc784d 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
@@ -29,7 +29,9 @@ import org.apache.hop.history.AuditManager;
 import org.apache.hop.history.AuditState;
 import org.apache.hop.history.AuditStateMap;
 import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.hopgui.HopGui;
 import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
 
 /**
  * Remembers SQL editor tabs across the Database perspective, floating window 
and bottom dock, and
@@ -52,13 +54,27 @@ final class DatabaseSqlTabMemory {
 
   static final int MAX_SQL_CHARS = 512_000;
   private static final int SAVE_DEBOUNCE_MS = 400;
+  static final String SESSION_KEY = DatabaseSqlTabMemory.class.getName();
 
   private DatabaseSqlTabMemory() {}
 
   static void restore(DatabaseWorkbench workbench) {
-    if (workbench == null || workbench.isDisposed() || 
workbench.hasSqlEditorTabs()) {
+    if (workbench == null || workbench.isDisposed()) {
+      return;
+    }
+    register(workbench);
+    if (workbench.hasSqlEditorTabs()) {
+      claim(workbench);
       return;
     }
+    Session existing = session(workbench);
+    if (existing != null
+        && existing.owner != null
+        && existing.owner != workbench
+        && !existing.owner.isDisposed()) {
+      handOff(existing.owner);
+    }
+    claim(workbench);
     try {
       String group = HopNamespace.getNamespace();
       AuditList list = AuditManager.getActive().retrieveList(group, 
AUDIT_TYPE);
@@ -115,7 +131,7 @@ final class DatabaseSqlTabMemory {
   }
 
   static void save(DatabaseWorkbench workbench) {
-    if (workbench == null || workbench.isDisposed()) {
+    if (workbench == null || workbench.isDisposed() || !isOwner(workbench)) {
       return;
     }
     try {
@@ -140,6 +156,121 @@ final class DatabaseSqlTabMemory {
     }
   }
 
+  static Session session(DatabaseWorkbench workbench) {
+    if (workbench == null) {
+      return null;
+    }
+    HopGui hopGui = workbench.hopGui();
+    if (hopGui == null || hopGui.getShell() == null || 
hopGui.getShell().isDisposed()) {
+      return null;
+    }
+    Shell shell = hopGui.getShell();
+    Object data = shell.getData(SESSION_KEY);
+    if (data instanceof Session existing) {
+      return existing;
+    }
+    Session created = new Session();
+    shell.setData(SESSION_KEY, created);
+    return created;
+  }
+
+  static void register(DatabaseWorkbench workbench) {
+    Session session = session(workbench);
+    if (session == null || workbench == null) {
+      return;
+    }
+    if (!session.live.contains(workbench)) {
+      session.live.add(workbench);
+    }
+  }
+
+  static void unregister(DatabaseWorkbench workbench) {
+    Session session = session(workbench);
+    if (session == null || workbench == null) {
+      return;
+    }
+    session.live.remove(workbench);
+    if (session.owner == workbench) {
+      session.owner = null;
+    }
+  }
+
+  static boolean isOwner(DatabaseWorkbench workbench) {
+    Session session = session(workbench);
+    if (session == null) {
+      return true;
+    }
+    return session.owner == workbench;
+  }
+
+  static void claim(DatabaseWorkbench workbench) {
+    Session session = session(workbench);
+    if (session != null) {
+      session.owner = workbench;
+    }
+  }
+
+  static void release(DatabaseWorkbench workbench) {
+    Session session = session(workbench);
+    if (session != null && session.owner == workbench) {
+      session.owner = null;
+    }
+  }
+
+  static void cancelScheduledSave(DatabaseWorkbench workbench) {
+    if (workbench == null || workbench.isDisposed()) {
+      return;
+    }
+    Display display = workbench.getDisplay();
+    if (display != null && !display.isDisposed()) {
+      display.timerExec(-1, workbench.persistSqlTabsRunnable);
+    }
+  }
+
+  /**
+   * Persist this workbench's tabs if it owns them, close the SQL editors 
without writing again, and
+   * drop ownership so another host can restore the snapshot.
+   */
+  static void handOff(DatabaseWorkbench workbench) {
+    if (workbench == null || workbench.isDisposed()) {
+      return;
+    }
+    cancelScheduledSave(workbench);
+    saveNow(workbench);
+    workbench.closeSqlEditorTabs();
+    release(workbench);
+  }
+
+  static void ensureOwner(DatabaseWorkbench workbench) {
+    if (workbench == null || workbench.isDisposed() || isOwner(workbench)) {
+      return;
+    }
+    Session session = session(workbench);
+    DatabaseWorkbench previous = session == null ? null : session.owner;
+    if (previous != null && previous != workbench && !previous.isDisposed()) {
+      handOff(previous);
+    }
+    claim(workbench);
+  }
+
+  static void restoreIntoRemaining(DatabaseWorkbench leaving) {
+    Session session = session(leaving);
+    if (session == null) {
+      return;
+    }
+    for (DatabaseWorkbench live : new ArrayList<>(session.live)) {
+      if (live != leaving && live != null && !live.isDisposed()) {
+        restore(live);
+        return;
+      }
+    }
+  }
+
+  static final class Session {
+    final List<DatabaseWorkbench> live = new ArrayList<>();
+    DatabaseWorkbench owner;
+  }
+
   static List<Snapshot> snapshotsFromAudit(AuditList list, AuditStateMap 
stateMap) {
     List<Snapshot> snapshots = new ArrayList<>();
     if (list == null || Utils.isEmpty(list.getNames()) || stateMap == null) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
index 8bf75598f6..10b4170fac 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
@@ -32,6 +32,7 @@ import org.apache.hop.core.database.Schema;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.search.SearchMatcher;
 import org.apache.hop.core.util.Utils;
@@ -49,6 +50,7 @@ import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.widget.TreeMemory;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
@@ -242,23 +244,37 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
                 host.asyncExec(
                     () -> {
                       closeSqlEditorTabs();
+                      operationsPanel.cancelAll();
+                      operationsPanel.clearAll();
                       reloadConnections();
-                      DatabaseSqlTabMemory.restore(this);
+                      if (DatabaseSqlTabMemory.isOwner(this)) {
+                        DatabaseSqlTabMemory.restore(this);
+                      }
                     }),
             HopGuiEvents.ProjectActivated.name());
 
     addDisposeListener(
         e -> {
+          boolean wasOwner = DatabaseSqlTabMemory.isOwner(this);
           DatabaseSqlTabMemory.saveNow(this);
           operationsPanel.cancelAll();
+          DatabaseSqlTabMemory.unregister(this);
+          if (wasOwner) {
+            DatabaseSqlTabMemory.restoreIntoRemaining(this);
+          }
           
host.getHopGui().getEventsHandler().removeEventListeners(eventListenerId);
           
host.getHopGui().getEventsHandler().removeEventListeners(eventListenerId + 
"-project");
         });
 
+    DatabaseSqlTabMemory.register(this);
     reloadConnections();
     DatabaseSqlTabMemory.restore(this);
   }
 
+  HopGui hopGui() {
+    return host.getHopGui();
+  }
+
   public DatabaseSqlFileType getSqlFileType() {
     return sqlFileType;
   }
@@ -967,14 +983,16 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
   }
 
   /**
-   * {@code SELECT * FROM schema.table} plus the dialect's limit clause ({@link
-   * DatabaseMeta#getLimitClause(int)}).
+   * {@code SELECT * FROM schema.table} plus the dialect's row limit. Some 
databases put the clause
+   * after {@code SELECT} ({@link DatabaseMeta#getLimitClausePrefix(int)}); 
others append it ({@link
+   * DatabaseMeta#getLimitClause(int)}). Matches {@code Database.getFirstRows}.
    */
   static String previewSelectSql(
       DatabaseMeta meta, IVariables variables, String schemaName, String 
tableName, int rowLimit) {
     String qualified = meta.getQuotedSchemaTableCombination(variables, 
schemaName, tableName);
-    String limit = Const.NVL(meta.getLimitClause(rowLimit), "");
-    return "SELECT * FROM " + qualified + limit;
+    String prefix = rowLimit > 0 ? 
Const.NVL(meta.getLimitClausePrefix(rowLimit), "") : "";
+    String limit = rowLimit > 0 ? Const.NVL(meta.getLimitClause(rowLimit), "") 
: "";
+    return "SELECT" + prefix + " * FROM " + qualified + limit;
   }
 
   @GuiToolbarElement(
@@ -1005,7 +1023,11 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
       image = "ui/images/detach-panel.svg",
       separator = true)
   public void openFloatingWindow() {
-    DatabaseSqlTabMemory.saveNow(this);
+    if (DatabaseWorkbenchViews.isDialogOpen(host.getHopGui())) {
+      DatabaseWorkbenchViews.openDialog(host.getHopGui());
+      return;
+    }
+    DatabaseSqlTabMemory.handOff(this);
     DatabaseWorkbenchViews.openDialog(host.getHopGui());
   }
 
@@ -1015,12 +1037,19 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
       toolTip = "i18n::DatabasePerspective.Toolbar.Dock.Tooltip",
       image = "ui/images/dock-panel.svg")
   public void openInBottomDock() {
-    DatabaseSqlTabMemory.saveNow(this);
+    if (DatabaseWorkbenchViews.isDockOpen(host.getHopGui())) {
+      DatabaseWorkbenchViews.openDock(host.getHopGui());
+      return;
+    }
+    DatabaseSqlTabMemory.handOff(this);
     DatabaseWorkbenchViews.openDock(host.getHopGui());
   }
 
   public DatabaseSqlEditorTab openSqlTab(
       DatabaseMeta meta, String sql, String filename, String buffer, boolean 
dirty) {
+    if (!restoringSqlTabs) {
+      DatabaseSqlTabMemory.ensureOwner(this);
+    }
     if (!Utils.isEmpty(filename)) {
       for (TabItemHandler item : items) {
         if (item.getTypeHandler() instanceof DatabaseSqlEditorTab tab
@@ -1049,7 +1078,8 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
         }
       }
     } else {
-      tab.setInitialText(Const.NVL(sql, ""));
+      String text = buffer != null ? buffer : Const.NVL(sql, "");
+      tab.applyBuffer(text, dirty);
     }
     tab.setTabItem(tabItem);
     tabItem.setControl(tab.getControl());
@@ -1263,6 +1293,7 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
             work.run(operation);
             operation.complete();
           } catch (Exception e) {
+            LogChannel.UI.logError(description, e);
             operation.fail(Const.NVL(e.getMessage(), e.toString()));
           }
           host.asyncExec(operationsPanel::refresh);
@@ -1322,15 +1353,28 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
     items.remove(item);
     IHopFileTypeHandler handler = item.getTypeHandler();
     if (handler != null && handler.getFilename() != null) {
-      host.getHopGui().fileRefreshDelegate.remove(handler.getFilename());
+      host.getHopGui().fileRefreshDelegate.remove(handler.getFilename(), 
handler);
     }
     if (item.getTabItem() != null && !item.getTabItem().isDisposed()) {
+      Control content = item.getTabItem().getControl();
+      if (content != null && !content.isDisposed()) {
+        content.dispose();
+      }
       item.getTabItem().dispose();
     }
     host.updateGui(getActiveFileTypeHandler());
     schedulePersistSqlTabs();
   }
 
+  boolean canCloseSqlTabs() {
+    for (TabItemHandler item : new ArrayList<>(items)) {
+      if (item.getTypeHandler() instanceof DatabaseSqlEditorTab tab && 
!tab.isCloseable()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
   @Override
   public void closeTab(CTabFolderEvent event, CTabItem tabItem) {
     if (tabItem == null || tabItem.isDisposed()) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchDialog.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchDialog.java
index b54454fa52..c375a5001b 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchDialog.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchDialog.java
@@ -122,7 +122,7 @@ public class DatabaseWorkbenchDialog {
     shell.setLayout(layout);
 
     HopGuiDatabaseWorkbenchHost host =
-        new HopGuiDatabaseWorkbenchHost(hopGui, this::isOpen, this::activate);
+        new HopGuiDatabaseWorkbenchHost(hopGui, this::isOpen, this::activate, 
shell);
     workbench = new DatabaseWorkbench(shell, host);
     workbench.setLayoutData(new FormDataBuilder().fullSize().result());
 
@@ -131,6 +131,14 @@ public class DatabaseWorkbenchDialog {
     hopGui.replaceKeyboardShortcutListeners(workbench, keyHandler);
     hopGui.replaceKeyboardShortcutListeners(shell, keyHandler);
 
+    shell.addListener(
+        SWT.Close,
+        event -> {
+          if (workbench != null && !workbench.isDisposed() && 
!workbench.canCloseSqlTabs()) {
+            event.doit = false;
+          }
+        });
+
     shell.addDisposeListener(
         e -> {
           props.setScreen(new WindowProperty(shell));
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViews.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViews.java
index f158fcf70c..7c19ae0e14 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViews.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViews.java
@@ -66,6 +66,15 @@ public class DatabaseWorkbenchViews {
     DatabaseWorkbenchDialog.open(hopGui);
   }
 
+  /** True when the floating Database window is already open. */
+  public static boolean isDialogOpen(HopGui hopGui) {
+    if (hopGui == null || hopGui.getShell() == null || 
hopGui.getShell().isDisposed()) {
+      return false;
+    }
+    Object existing = 
hopGui.getShell().getData(DatabaseWorkbenchDialog.SHELL_DATA_KEY);
+    return existing instanceof DatabaseWorkbenchDialog dialog && 
dialog.isOpen();
+  }
+
   /** Open or focus the Database tab in the bottom dock. */
   public static void openDock(HopGui hopGui) {
     if (hopGui == null) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
index 1536ab21f4..a56492d9b1 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
@@ -32,11 +32,18 @@ public class HopGuiDatabaseWorkbenchHost implements 
IDatabaseWorkbenchHost {
   private final HopGui hopGui;
   private final BooleanSupplier alive;
   private final Runnable onActivate;
+  private final Shell dialogShell;
 
   public HopGuiDatabaseWorkbenchHost(HopGui hopGui, BooleanSupplier alive, 
Runnable onActivate) {
+    this(hopGui, alive, onActivate, null);
+  }
+
+  public HopGuiDatabaseWorkbenchHost(
+      HopGui hopGui, BooleanSupplier alive, Runnable onActivate, Shell 
dialogShell) {
     this.hopGui = hopGui;
     this.alive = alive;
     this.onActivate = onActivate;
+    this.dialogShell = dialogShell;
   }
 
   @Override
@@ -46,6 +53,9 @@ public class HopGuiDatabaseWorkbenchHost implements 
IDatabaseWorkbenchHost {
 
   @Override
   public Shell getShell() {
+    if (dialogShell != null && !dialogShell.isDisposed()) {
+      return dialogShell;
+    }
     return hopGui.getShell();
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 5c271227bf..985790723e 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -2004,7 +2004,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @SuppressWarnings("javabugs:S2259") // callers always pass an open file type 
handler
   protected void changeFilename(IHopFileTypeHandler fileTypeHandler, String 
newFilename) {
     String oldFilename = fileTypeHandler.getFilename();
-    hopGui.fileRefreshDelegate.remove(oldFilename);
+    hopGui.fileRefreshDelegate.remove(oldFilename, fileTypeHandler);
     fileTypeHandler.setFilename(newFilename);
     hopGui.fileRefreshDelegate.register(newFilename, fileTypeHandler);
   }
@@ -2408,7 +2408,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       items.remove(toRemove);
       IHopFileTypeHandler fileTypeHandler = toRemove.getTypeHandler();
       if (fileTypeHandler != null && fileTypeHandler.getFilename() != null) {
-        hopGui.fileRefreshDelegate.remove(fileTypeHandler.getFilename());
+        hopGui.fileRefreshDelegate.remove(fileTypeHandler.getFilename(), 
fileTypeHandler);
       }
     }
     tabItem.dispose();
@@ -2432,7 +2432,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
     IHopFileTypeHandler fileTypeHandler = item.getTypeHandler();
     if (fileTypeHandler != null && fileTypeHandler.getFilename() != null) {
-      hopGui.fileRefreshDelegate.remove(fileTypeHandler.getFilename());
+      hopGui.fileRefreshDelegate.remove(fileTypeHandler.getFilename(), 
fileTypeHandler);
     }
 
     if (!hopGui.fileDelegate.isClosing()) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
index 4cdba0f323..6f1fcf1011 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
@@ -574,6 +574,15 @@ public class HopGuiBottomDock extends Composite implements 
TabClosable {
       unregisterTerminal(terminalId);
     }
 
+    Object toolContent = tabItem.getData(DATA_TOOL_CONTENT);
+    if (toolContent instanceof Control content && !content.isDisposed()) {
+      content.dispose();
+    }
+    Control tabControl = tabItem.getControl();
+    if (tabControl != null && !tabControl.isDisposed()) {
+      tabControl.dispose();
+    }
+
     tabItem.dispose();
   }
 
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/database/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/database/messages/messages_en_US.properties
index aadc58001a..bba654a925 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/database/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/database/messages/messages_en_US.properties
@@ -47,6 +47,7 @@ DatabasePerspective.ConnectToExecute.Title=Connect to 
database?
 DatabasePerspective.ConnectToExecute.Message=You are not connected to ''{0}''. 
Connect now to execute this SQL?
 DatabasePerspective.ConnectToExecute.Toggle=Don't ask again, change this 
option in the configuration perspective
 DatabasePerspective.SqlTab.Cancelled=Cancelled
+DatabasePerspective.SqlTab.Failed=Failed
 DatabasePerspective.SqlTab.QueryRows=Query {0}: {1} row(s)
 DatabasePerspective.SqlTab.QueryRowsLimited=Query {0}: {1} row(s). Results are 
capped at {2} rows by the Database perspective (a SQL LIMIT above this value is 
ignored). Change the query row limit under Configuration > Plugins > Database 
Perspective.
 DatabasePerspective.SqlTab.Executed=OK: {0}
@@ -72,6 +73,7 @@ DatabasePerspective.Operations.Status.Done=Done
 DatabasePerspective.Operations.Status.Failed=Failed
 DatabasePerspective.Operations.Status.Cancelled=Cancelled
 DatabasePerspective.Operations.Kill.Tooltip=Cancel the selected running 
operation
+DatabasePerspective.Operations.Clear.Tooltip=Remove finished operations from 
the list
 DatabasePerspective.Operations.KillCurrent.Tooltip=Cancel the current running 
operation
 DatabasePerspective.Operations.Minimize.Tooltip=Show a one-line status instead 
of the operations list
 DatabasePerspective.Operations.Expand.Tooltip=Show the operations list
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/dialog/RowPreviewSupportTest.java 
b/ui/src/test/java/org/apache/hop/ui/core/dialog/RowPreviewSupportTest.java
new file mode 100644
index 0000000000..0c0519a1b5
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/core/dialog/RowPreviewSupportTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.core.dialog;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.row.value.ValueMetaBinary;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class RowPreviewSupportTest {
+
+  @BeforeAll
+  static void initHop() throws Exception {
+    HopEnvironment.init();
+  }
+
+  @Test
+  void binaryCellIsHexEncodedUnlessAvoided() throws Exception {
+    ValueMetaBinary meta = new ValueMetaBinary("payload");
+    byte[] bytes = new byte[] {0x0a, 0x0b};
+    assertEquals("0a0b", RowPreviewSupport.formatCell(meta, bytes, false));
+    assertEquals(meta.getString(bytes), RowPreviewSupport.formatCell(meta, 
bytes, true));
+  }
+
+  @Test
+  void nullBinaryIsNull() throws Exception {
+    ValueMetaBinary meta = new ValueMetaBinary("payload");
+    assertNull(RowPreviewSupport.formatCell(meta, null, false));
+  }
+
+  @Test
+  void stringCellUsesValueMeta() throws Exception {
+    ValueMetaString meta = new ValueMetaString("name");
+    assertEquals("Ada", RowPreviewSupport.formatCell(meta, "Ada", false));
+  }
+
+  @Test
+  void formatColumnMetaTooltipDelegates() {
+    ValueMetaString meta = new ValueMetaString("customer_name");
+    meta.setLength(100);
+    String tip = RowPreviewSupport.formatColumnMetaTooltip(meta);
+    assertTrue(tip.contains("customer_name"));
+    assertTrue(tip.contains("100"));
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/delegates/FileRefreshHandlerMapTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/delegates/FileRefreshHandlerMapTest.java
new file mode 100644
index 0000000000..e58161a1dd
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/delegates/FileRefreshHandlerMapTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.delegates;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
+import org.junit.jupiter.api.Test;
+
+class FileRefreshHandlerMapTest {
+
+  @Test
+  void twoHandlersShareAWatchUntilTheLastIsRemoved() {
+    FileRefreshHandlerMap map = new FileRefreshHandlerMap();
+    EmptyHopFileTypeHandler explorer = new EmptyHopFileTypeHandler();
+    EmptyHopFileTypeHandler database = new EmptyHopFileTypeHandler();
+
+    assertTrue(map.add("file:///tmp/q.sql", explorer));
+    assertFalse(map.add("file:///tmp/q.sql", database));
+    map.alias("file:///tmp/q.sql", "/tmp/q.sql");
+    assertEquals(2, map.get("file:///tmp/q.sql").size());
+    assertEquals(2, map.get("/tmp/q.sql").size());
+
+    assertFalse(map.remove("/tmp/q.sql", database));
+    assertEquals(1, map.get("file:///tmp/q.sql").size());
+    assertTrue(map.remove("file:///tmp/q.sql", explorer));
+    assertTrue(map.get("file:///tmp/q.sql").isEmpty());
+    assertTrue(map.get("/tmp/q.sql").isEmpty());
+  }
+
+  @Test
+  void removeAllDropsEveryHandler() {
+    FileRefreshHandlerMap map = new FileRefreshHandlerMap();
+    map.add("a.sql", new EmptyHopFileTypeHandler());
+    map.add("a.sql", new EmptyHopFileTypeHandler());
+    assertTrue(map.removeAll("a.sql"));
+    assertTrue(map.get("a.sql").isEmpty());
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
index 812af83180..87d8df2e86 100644
--- 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanelTest.java
@@ -18,8 +18,11 @@
 package org.apache.hop.ui.hopgui.perspective.database;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
 
+import org.apache.hop.core.database.Database;
 import org.junit.jupiter.api.Test;
 
 class DatabaseOperationsPanelTest {
@@ -40,7 +43,25 @@ class DatabaseOperationsPanelTest {
     operation.fail("boom");
     String line = DatabaseOperationsPanel.formatStatusLine(operation);
     assertTrue(line.startsWith("Connect - Failed - "));
-    assertTrue(line.endsWith(" ms") || line.contains(" s"));
+    assertTrue(line.contains("boom"));
+    assertTrue(line.endsWith(" ms") || line.contains(" s") || 
line.contains("boom"));
+  }
+
+  @Test
+  void trimFinishedKeepsNewestFinishedAndAllRunning() {
+    java.util.List<DatabaseOperation> operations = new java.util.ArrayList<>();
+    DatabaseOperation running = new DatabaseOperation("run", "db");
+    operations.add(running);
+    for (int i = 0; i < 5; i++) {
+      DatabaseOperation done = new DatabaseOperation("done-" + i, "db");
+      done.complete();
+      operations.add(done);
+    }
+    DatabaseOperationsPanel.trimFinished(operations, 2);
+    assertEquals(3, operations.size());
+    assertEquals(running, operations.get(0));
+    assertEquals("done-0", operations.get(1).getDescription());
+    assertEquals("done-1", operations.get(2).getDescription());
   }
 
   @Test
@@ -48,6 +69,14 @@ class DatabaseOperationsPanelTest {
     assertEquals("", DatabaseOperationsPanel.formatStatusLine(null));
   }
 
+  @Test
+  void failDetachesDatabase() {
+    DatabaseOperation operation = new DatabaseOperation("Connect", "shop");
+    operation.attachDatabase(mock(Database.class));
+    operation.fail("boom");
+    assertNull(operation.getDatabase().get());
+  }
+
   @Test
   void formatElapsed() {
     assertEquals("0 ms", DatabaseOperationsPanel.formatElapsed(0));
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchPreviewSqlTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchPreviewSqlTest.java
index c4496acfe8..15a246cff3 100644
--- 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchPreviewSqlTest.java
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchPreviewSqlTest.java
@@ -65,4 +65,18 @@ class DatabaseWorkbenchPreviewSqlTest {
     assertEquals(
         "SELECT * FROM t", DatabaseWorkbench.previewSelectSql(meta, variables, 
null, "t", 1000));
   }
+
+  @Test
+  void previewSelectSqlUsesPrefixLimitClause() {
+    DatabaseMeta meta = mock(DatabaseMeta.class);
+    IVariables variables = mock(IVariables.class);
+    when(meta.getQuotedSchemaTableCombination(any(), eq("dbo"), eq("mytable")))
+        .thenReturn("dbo.mytable");
+    when(meta.getLimitClausePrefix(1000)).thenReturn(" TOP 1000");
+    when(meta.getLimitClause(1000)).thenReturn("");
+
+    assertEquals(
+        "SELECT TOP 1000 * FROM dbo.mytable",
+        DatabaseWorkbench.previewSelectSql(meta, variables, "dbo", "mytable", 
1000));
+  }
 }
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViewsTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViewsTest.java
index 630096ab75..9b857ba218 100644
--- 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViewsTest.java
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViewsTest.java
@@ -34,6 +34,11 @@ class DatabaseWorkbenchViewsTest {
     assertFalse(DatabaseWorkbenchViews.isDockOpen(null));
   }
 
+  @Test
+  void dialogIsNotOpenWithoutHopGui() {
+    assertFalse(DatabaseWorkbenchViews.isDialogOpen(null));
+  }
+
   @Test
   void openDialogAndDockTolerateNullHopGui() {
     assertDoesNotThrow(() -> DatabaseWorkbenchViews.openDialog(null));

Reply via email to