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 3dbf73c1f3 cancel button does not react properly, fixes #8079 (#8090)
3dbf73c1f3 is described below

commit 3dbf73c1f3dd38c9eb559135ed84817a19af2d55
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Tue Aug 25 15:22:50 2026 +0200

    cancel button does not react properly, fixes #8079 (#8090)
---
 .../explorer/ExplorerPerspectiveTabCloseTest.java  | 358 +++++++++++++++++++++
 .../perspective/explorer/ExplorerPerspective.java  |  11 +-
 2 files changed, 365 insertions(+), 4 deletions(-)

diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspectiveTabCloseTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspectiveTabCloseTest.java
new file mode 100644
index 0000000000..e0f0bd5b0f
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspectiveTabCloseTest.java
@@ -0,0 +1,358 @@
+/*
+ * 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.perspective.explorer;
+
+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 java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.delegates.HopGuiAuditDelegate;
+import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
+import org.apache.hop.ui.hopgui.perspective.explorer.file.ExplorerFileType;
+import 
org.apache.hop.ui.hopgui.perspective.explorer.file.types.base.BaseExplorerFileTypeHandler;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+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.layout.FillLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Closing the tab of a file with unsaved changes asks the user what to do. 
Answering Cancel means
+ * "don't close": the tab has to stay exactly where it was.
+ *
+ * <p>Issue #8079 - the explorer perspective vetoed the close event but 
disposed the tab itself
+ * right before, so the file closed anyway whatever the user answered.
+ *
+ * <p>The whole chain is exercised for real: the close button of a real {@link 
CTabFolder} is
+ * clicked, SWT fires its close event, {@link ExplorerPerspective#closeTab} 
runs, the file type
+ * handler puts up the real save prompt, and SWT decides on the event's {@code 
doit} whether to
+ * dispose the tab.
+ */
+@Tag("uitest")
+class ExplorerPerspectiveTabCloseTest extends SwtBotTestBase {
+
+  private static final String CHANGED_FILE = "changed.txt";
+  private static final String UNCHANGED_FILE = "unchanged.txt";
+
+  /** Title of the prompt {@code BaseExplorerFileTypeHandler#isCloseable} puts 
up. */
+  private static final String SAVE_PROMPT = "Save file?";
+
+  /** How long to wait for the save prompt to appear or disappear. */
+  private static final int POLL_ATTEMPTS = 100;
+
+  private static final int POLL_MILLIS = 50;
+
+  private final AtomicReference<CTabFolder> folder = new AtomicReference<>();
+  private final AtomicReference<CTabItem> changedTab = new AtomicReference<>();
+
+  @Test
+  void cancellingTheSavePromptKeepsTheFileOpen() {
+    withScene(
+        this::twoOpenFiles,
+        bot ->
+            closeTabAndAnswer(
+                bot,
+                buttonLabel("System.Button.Cancel"),
+                () -> {
+                  assertFalse(
+                      onUi(() -> changedTab.get().isDisposed()),
+                      "Cancel means 'do not close': the file has to stay open 
(issue #8079)");
+                  assertEquals(
+                      2,
+                      onUi(() -> folder.get().getItemCount()),
+                      "no tab may be closed after Cancel");
+                }));
+  }
+
+  /** The counterpart: answering the prompt with No does close the file, 
without saving it. */
+  @Test
+  void answeringNoClosesTheFileWithoutSaving() {
+    withScene(
+        this::twoOpenFiles,
+        bot ->
+            closeTabAndAnswer(
+                bot,
+                buttonLabel("System.Button.No"),
+                () -> {
+                  assertTrue(
+                      onUi(() -> changedTab.get().isDisposed()),
+                      "No means: close the file without saving");
+                  assertEquals(
+                      1,
+                      onUi(() -> folder.get().getItemCount()),
+                      "only the closed tab may disappear");
+                }));
+  }
+
+  // ---------------------------------------------------------------- the scene
+
+  /**
+   * Two open files in one editor pane of the explorer perspective, the first 
one with unsaved
+   * changes. Everything the close path touches is the real thing; only the 
surrounding application
+   * chrome - the file tree, the toolbars, the audit log - is left out, as it 
is built when the Hop
+   * GUI shell opens, which a test never does.
+   */
+  private void twoOpenFiles(Shell shell) {
+    shell.setLayout(new FillLayout());
+
+    HopGui hopGui = testHopGui(shell);
+    ExplorerPerspective perspective = new ExplorerPerspective();
+    setField(perspective, "hopGui", hopGui);
+
+    CTabFolder tabFolder = new CTabFolder(shell, SWT.BORDER | SWT.CLOSE);
+    tabFolder.addCTabFolder2Listener(
+        new CTabFolder2Adapter() {
+          @Override
+          public void close(CTabFolderEvent event) {
+            perspective.closeTab(event, (CTabItem) event.item);
+          }
+        });
+    // The perspective finds its open tabs through the editor layout tree; a 
single pane is that
+    // one tab folder.
+    setField(perspective, "editorRoot", tabFolder);
+
+    changedTab.set(openFile(perspective, hopGui, tabFolder, CHANGED_FILE, 
true));
+    openFile(perspective, hopGui, tabFolder, UNCHANGED_FILE, false);
+
+    // Only the selected tab shows a close button, and that is the one to 
click.
+    tabFolder.setSelection(changedTab.get());
+    folder.set(tabFolder);
+    shell.layout(true, true);
+  }
+
+  /** Opens one file in a tab, exactly the way the perspective registers a 
file it opened. */
+  private CTabItem openFile(
+      ExplorerPerspective perspective,
+      HopGui hopGui,
+      CTabFolder tabFolder,
+      String name,
+      boolean changed) {
+    ExplorerFile explorerFile = new ExplorerFile(name, name, new 
ExplorerFileType());
+    TestFileTypeHandler handler = new TestFileTypeHandler(hopGui, perspective, 
explorerFile);
+    if (changed) {
+      handler.setChanged();
+    }
+
+    CTabItem tabItem = new CTabItem(tabFolder, SWT.CLOSE);
+    tabItem.setText(name);
+    tabItem.setData(handler);
+    perspectiveItems(perspective).add(new TabItemHandler(tabItem, handler));
+    return tabItem;
+  }
+
+  /**
+   * A Hop GUI with a real set of delegates, hung off the test shell so the 
save prompt has a parent
+   * to open on. Writing the list of open files is the one thing stubbed out: 
it needs the
+   * perspective manager of a fully opened application and says nothing about 
closing a tab.
+   */
+  private HopGui testHopGui(Shell shell) {
+    HopGui hopGui = new TestHopGui();
+    hopGui.getShell().dispose();
+    hopGui.setShell(shell);
+    hopGui.setProps(PropsUi.getInstance());
+    hopGui.auditDelegate =
+        new HopGuiAuditDelegate(hopGui) {
+          @Override
+          public void writeLastOpenFiles() {
+            // not what this test is about
+          }
+        };
+    return hopGui;
+  }
+
+  // ---------------------------------------------------------------- 
interactions
+
+  /**
+   * Clicks the close button of the changed file's tab, answers the save 
prompt that comes up, and
+   * only then runs the assertions.
+   *
+   * <p>The prompt runs its own event loop on the UI thread, so this worker 
gets control back while
+   * it is on screen - and the UI thread stays inside that loop until the 
prompt is answered.
+   * Whatever happens, the prompt is dismissed at the end: a worker walking 
away from an open prompt
+   * would leave the UI thread parked in that loop forever.
+   */
+  private void closeTabAndAnswer(SWTBot bot, String answer, Runnable 
assertions) {
+    try {
+      bot.cTabItem(CHANGED_FILE).close();
+
+      Shell prompt = awaitSavePrompt();
+      new SWTBot(prompt).button(answer).click();
+      waitUntilDisposed(prompt);
+
+      assertions.run();
+    } finally {
+      dismissAnyOpenPrompt();
+    }
+  }
+
+  /** The save prompt, once the close path has put it up. */
+  private Shell awaitSavePrompt() {
+    for (int attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
+      Shell prompt = onUi(() -> shellTitled(SAVE_PROMPT));
+      if (prompt != null) {
+        return prompt;
+      }
+      pause();
+    }
+    throw new AssertionError(
+        "closing the tab of a changed file should ask whether to save it, but 
only these windows "
+            + "are open: "
+            + onUi(this::shellTitles));
+  }
+
+  private void waitUntilDisposed(Shell prompt) {
+    for (int attempt = 0; attempt < POLL_ATTEMPTS && 
!onUi(prompt::isDisposed); attempt++) {
+      pause();
+    }
+    assertTrue(onUi(prompt::isDisposed), "the answered save prompt should have 
closed");
+  }
+
+  /** Hands the UI thread back its event loop, however the interactions ended. 
*/
+  private void dismissAnyOpenPrompt() {
+    display.asyncExec(
+        () -> {
+          Shell prompt = shellTitled(SAVE_PROMPT);
+          if (prompt != null) {
+            prompt.close();
+          }
+        });
+    display.wake();
+  }
+
+  private Shell shellTitled(String title) {
+    for (Shell open : display.getShells()) {
+      if (!open.isDisposed() && title.equals(open.getText())) {
+        return open;
+      }
+    }
+    return null;
+  }
+
+  private List<String> shellTitles() {
+    List<String> titles = new ArrayList<>();
+    for (Shell open : display.getShells()) {
+      if (!open.isDisposed()) {
+        titles.add("'" + open.getText() + "'");
+      }
+    }
+    return titles;
+  }
+
+  private static void pause() {
+    try {
+      Thread.sleep(POLL_MILLIS);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new AssertionError("interrupted while waiting for the save 
prompt", e);
+    }
+  }
+
+  // ---------------------------------------------------------------- test 
doubles
+
+  /** The Hop GUI constructor is protected; a test builds one through a 
subclass. */
+  private static class TestHopGui extends HopGui {
+    // no additions: the delegates the close path uses are the real ones
+  }
+
+  /**
+   * The real explorer file handler - the save prompt and its Yes/No/Cancel 
answers come from {@link
+   * BaseExplorerFileTypeHandler} unchanged. Only rendering the file is left 
out: a tab does not
+   * need contents to be closed.
+   */
+  private static class TestFileTypeHandler extends BaseExplorerFileTypeHandler 
{
+    TestFileTypeHandler(HopGui hopGui, ExplorerPerspective perspective, 
ExplorerFile explorerFile) {
+      super(hopGui, perspective, explorerFile);
+    }
+
+    @Override
+    public void renderFile(Composite composite) {
+      // nothing to render
+    }
+  }
+
+  // ---------------------------------------------------------------- UI thread
+
+  /** Runs {@code supplier} on the UI thread and hands its result back to the 
SWTBot worker. */
+  private static <T> T onUi(Supplier<T> supplier) {
+    AtomicReference<T> result = new AtomicReference<>();
+    AtomicReference<RuntimeException> failure = new AtomicReference<>();
+    display.syncExec(
+        () -> {
+          try {
+            result.set(supplier.get());
+          } catch (RuntimeException e) {
+            failure.set(e);
+          }
+        });
+    if (failure.get() != null) {
+      throw failure.get();
+    }
+    return result.get();
+  }
+
+  // ---------------------------------------------------------------- 
reflection
+
+  @SuppressWarnings("unchecked")
+  private static List<TabItemHandler> perspectiveItems(ExplorerPerspective 
perspective) {
+    return (List<TabItemHandler>) readField(perspective, "items");
+  }
+
+  private static Object readField(Object target, String name) {
+    try {
+      Field field = field(target, name);
+      return field.get(target);
+    } catch (IllegalAccessException e) {
+      throw new AssertionError("Could not read " + name, e);
+    }
+  }
+
+  private static void setField(Object target, String name, Object value) {
+    try {
+      field(target, name).set(target, value);
+    } catch (IllegalAccessException e) {
+      throw new AssertionError("Could not set " + name, e);
+    }
+  }
+
+  private static Field field(Object target, String name) {
+    for (Class<?> type = target.getClass(); type != null; type = 
type.getSuperclass()) {
+      try {
+        Field field = type.getDeclaredField(name);
+        field.setAccessible(true);
+        return field;
+      } catch (NoSuchFieldException e) {
+        // keep walking up the hierarchy
+      }
+    }
+    throw new AssertionError("No field '" + name + "' on " + 
target.getClass());
+  }
+}
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 1c2e3e2ae8..a43e14e68c 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
@@ -2249,14 +2249,17 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       return;
     }
     IHopFileTypeHandler fileTypeHandler = (IHopFileTypeHandler) 
tabItem.getData();
-    boolean isRemoved = false;
+    // A tab without a handler is broken: nothing can veto its close.
+    boolean isRemoved = true;
     if (fileTypeHandler != null) {
+      // This is where the user gets asked to save the file. Answering Cancel 
means: keep the file
+      // open, so never dispose the tab afterwards (issue #8079).
       isRemoved = remove(fileTypeHandler);
     }
-    // If remove failed (e.g. null/broken handler) or tab is still there, 
close it directly
-    if (!tabItem.isDisposed()) {
+    // The close wasn't vetoed but the tab is still there: the handler wasn't 
registered in this
+    // perspective (a broken tab). Dispose it directly so the user can still 
close it.
+    if (isRemoved && !tabItem.isDisposed()) {
       removeHandlerAndDisposeTab(tabItem);
-      isRemoved = true;
     }
     // Skip during bulk close (project/environment switch): writeLastOpenFiles 
was already called
     // before closeAllFiles; writing here would persist an empty open-files 
list (issue #7692).

Reply via email to