You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/10/13 11:28:32 UTC

(hop) branch main updated: Issue #8873 : VFS File Explorer: hardening and release gaps (#8876)

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 45d63ffa7a Issue #8873 : VFS File Explorer: hardening and release gaps (#8876)
45d63ffa7a is described below

commit 45d63ffa7a6913ae25b5380509913457201ad111
Author: Bart Maertens <ba...@know.bi>
AuthorDate: Tue Oct 13 13:28:27 2026 +0200

    Issue #8873 : VFS File Explorer: hardening and release gaps (#8876)
    
    * Issue #8873 : VFS File Explorer: hardening and release gaps
    
    * Issue #8873 : Address review: drop copies, partial cut, drag permission, safer replace
---
 .../ROOT/pages/hop-gui/vfs-file-explorer.adoc      |  102 +-
 .../ui/hopgui/BrowserFileTransferFacadeImpl.java   |   91 ++
 .../apache/hop/ui/hopgui/HopWebUserFilePlugin.java |    2 +-
 .../hopgui/vfs/explorer/VfsFileExplorerTest.java   |  183 +++
 .../hop/ui/hopgui/BrowserFileTransferFacade.java   |  109 ++
 .../perspective/explorer/ExplorerPerspective.java  |    6 +
 .../hopgui/vfs/explorer/VfsExplorerOperation.java  |   51 +
 .../vfs/explorer/VfsExplorerOperationsPanel.java   |   43 +-
 .../ui/hopgui/vfs/explorer/VfsFileClipboard.java   |   59 +-
 .../ui/hopgui/vfs/explorer/VfsFileExplorer.java    |   94 +-
 .../hopgui/vfs/explorer/VfsFileExplorerDialog.java |   14 +-
 .../vfs/explorer/VfsFileExplorerLocation.java      | 1446 ++++++++++++++++----
 .../hopgui/vfs/explorer/VfsFileExplorerViews.java  |   44 +-
 .../ui/hopgui/vfs/explorer/VfsFileExplorers.java   |   42 -
 .../hop/ui/hopgui/vfs/explorer/VfsFileListing.java |   33 +-
 .../ui/hopgui/vfs/explorer/VfsFileTransfer.java    |  424 +++++-
 .../ui/hopgui/vfs/explorer/VfsListingCounts.java   |   31 +-
 .../hop/ui/hopgui/vfs/explorer/VfsLocations.java   |   60 +-
 .../hop/ui/hopgui/vfs/explorer/VfsPlaces.java      |  155 +++
 .../explorer/messages/messages_en_US.properties    |   44 +-
 .../hopgui/vfs/explorer/VfsExplorerStatusTest.java |    3 +
 .../ui/hopgui/vfs/explorer/VfsFileListingTest.java |   16 +
 .../hopgui/vfs/explorer/VfsFileTransferTest.java   |  332 ++++-
 .../ui/hopgui/vfs/explorer/VfsLocationsTest.java   |   25 +
 .../hop/ui/hopgui/vfs/explorer/VfsPlacesTest.java  |   87 ++
 25 files changed, 3016 insertions(+), 480 deletions(-)

diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/vfs-file-explorer.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/vfs-file-explorer.adoc
index 6a8b694b08..6478ea6c5f 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/vfs-file-explorer.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/vfs-file-explorer.adoc
@@ -27,6 +27,8 @@ The same browser can sit in the bottom panel while you work on a pipeline or wor
 Use the dock and undock buttons to move the current location between the window and that panel.
 Each host keeps its own tabs.
 
+A new explorer starts in the project home folder, or in your home folder when no project is enabled.
+
 == What you see
 
 Each tab is one location.
@@ -36,21 +38,33 @@ Enter a path or a VFS URI to go there.
 Variables are resolved.
 * The tree on the left lists folders and loads a folder when you expand it.
 Select more than one folder to copy, cut or delete them together.
+* Below the tree, **Places** lists:
+** **Project**: the project home folder.
+** **Connections**: every saved file-storage connection of the project (S3, MinIO, Azure, Google Storage, FTP, SFTP, WebDAV, HDFS, Databricks, and so on).
+Click one to open its root.
+Use the refresh button after you create or rename a connection.
+** **This computer**: your home folder and the file system roots, such as `/` or the drive letters on Windows.
+On Hop Web this section is called **Server**, because it is the disk of the Hop Web server.
+** **Bookmarks**: the same list as in the file dialog, including the option that makes them global or per project.
 * The table on the right lists the files and folders in the selected folder.
 You can sort by clicking a column.
 Name is always shown.
 Size, last modified, owner, extension and permissions can be turned off from the columns button.
 Owner and permissions stay empty when the VFS provider does not report them.
+* A folder with more than 5000 entries shows the first 5000.
+The status line says how many are not loaded yet.
+**Load more** adds the next 5000.
+Sorting and the filter apply to the entries that are loaded.
 * The filter box limits the table to the current folder.
 It does not search subfolders.
 * **Show or hide hidden folders** applies to the tree.
 **Show or hide hidden files** applies to the table.
 A hidden name starts with a dot.
 The two switches are independent.
-* Bookmarks are the same list as in the file dialog, including the option that makes them global or per project.
 * The status line shows the listing or file change that is running, how long it took, how many rows are listed and their size, and how many rows are selected and their size.
-Stop interrupts that work.
-Expand shows the recent operations.
+A copy or move also shows how many files and bytes it wrote so far.
+Stop interrupts the operation that is shown, and only that one.
+Expand shows the recent operations: there, Stop interrupts the selected operation.
 * **Open a new location** adds a tab for the folder you are viewing.
 Ctrl+T does the same, and Cmd+T on macOS.
 
@@ -58,21 +72,80 @@ Double-click a folder to open it.
 Double-click a Hop file (a pipeline, a workflow, a text file, and so on) to open it in Hop.
 Double-click a zip, jar, tar, tgz or tbz2 archive to browse inside it.
 
-== Copy, rename and download
+== Keyboard shortcuts
+
+[options="header"]
+|===
+|Key |Action
+|Ctrl+C, Ctrl+X, Ctrl+V |Copy, cut or paste the selection of the tree or of the table, whichever has focus
+|Delete |Delete the selection of the tree or of the table
+|F2 |Rename
+|F5 |Refresh
+|Backspace, Alt+Up |Go to the parent folder
+|Ctrl+L |Go to the location bar
+|Ctrl+T |Open a new location tab
+|===
+
+On macOS use Cmd instead of Ctrl, and Cmd+Up to go to the parent folder.
+In the location bar and the filter box the usual text editing keys apply instead.
+
+== Copy, move and delete
 
 The folder toolbar copies, cuts, pastes, renames and deletes folders.
 Rename applies to one folder.
 Delete is set apart on the right-click menu so it is not next to the other actions.
 
 The file toolbar copies, cuts, pastes and deletes the rows you select, and renames a file when exactly one file is selected.
-**Download** opens the VFS save dialog with that file's name so you can store a copy somewhere else.
-**Upload** is next to it.
-Select one or more files, then choose the folder to place them in.
-That folder can be any VFS location, including a named cloud connection.
-**Open as text file** opens the file in a text editor even when Hop has a richer editor for it.
 Paste goes into the folder you are viewing.
 Copy and cut stay inside Hop: they are not the system clipboard.
-A folder is not copied into itself, its parent, or one of its own subfolders.
+A folder is not copied into itself or one of its own subfolders.
+
+You can also drag files and folders onto a folder in the tree or in the table, or onto the table to drop them in the folder you are viewing.
+A drop copies the files: the originals stay where they are.
+On the desktop, hold Shift while dropping (on Linux and Windows) to move them instead.
+On Hop Web a drop always copies: use cut and paste to move files.
+Files dragged from the File Explorer perspective or, on the desktop, from your file manager are accepted too.
+When a folder and a file or folder inside it are dropped together, the folder is copied once with everything in it.
+
+When a file already exists in the target folder you choose, for that file, between **Replace**, **Skip** and **Cancel**.
+**Replace all** and **Skip all** answer for the remaining files of the same paste.
+A folder that already exists is merged: its other files stay, and each file that exists in both is asked about.
+A file is never replaced by a folder, or a folder by a file.
+
+A replaced file is first written next to the original under a temporary hidden name.
+Only once the new copy is complete is the original set aside, the new copy given its name, and the original removed.
+A failed or stopped copy leaves the original untouched, and so does a failed rename: the original is put back.
+If even that is not possible, the error names the hidden files that hold the original and the new copy.
+A new file that is only partly written when a copy fails or is stopped is removed, and so is a new folder with what was already copied into it.
+
+When some files of a paste cannot be copied or moved, the others still are, and the error lists each file that failed.
+After a cut and paste, the files that were moved leave the explorer clipboard.
+Files you skipped, or that could not be moved, stay on it, so you can paste them again.
+
+Right-click a file or folder and choose **Copy path** to put its path on the system clipboard.
+**Copy path as '${PROJECT_HOME}/...'** writes it relative to the project home folder, ready to paste into a transform or action.
+
+== Save a copy, copy to a folder, download and upload
+
+On the desktop:
+
+* **Save a copy as** opens the VFS save dialog with the name of the selected file, so you can store a copy anywhere, including on a named connection.
+* **Copy to folder** copies the selected files to a folder you choose.
+
+On Hop Web the same buttons move files between the browser and the server:
+
+* **Download** sends the selected file to your browser.
+It needs the export permission.
+* **Upload to this folder** uploads a file from your computer into the folder you are viewing, up to 512 MB.
+
+**Open as text file** opens the file in a text editor even when Hop has a richer editor for it.
+
+== Permissions on Hop Web
+
+When Hop Web runs with users and roles, a user who may not change files in the explorer (`explorer.write`) can browse and copy to the explorer clipboard, but cannot paste, cut, rename, delete, create folders, save a copy, upload or drag files.
+The File Explorer perspective also refuses a drop from such a user.
+Opening the explorer needs `file.view`.
+Each Hop Web session has its own explorer clipboard.
 
 == Named connections
 
@@ -80,7 +153,8 @@ On the editor of a file-storage connection (S3, MinIO, Azure, Google Storage, FT
 An HDFS connection opens at `+name://+`.
 That is the root of the connection.
 A configured default path, such as an HDFS default root or a Databricks base path, is applied by the connection itself.
-Save the connection first: Explore follows the saved definition, and saves unsaved edits before it opens.
+Explore follows the saved definition.
+When the editor has unsaved changes, it asks to save them first.
 
 == From the File Explorer perspective
 
@@ -89,3 +163,9 @@ A folder opens itself.
 A file opens its parent folder.
 When the VFS File Explorer is already showing in the bottom panel, the folder is added there.
 Otherwise it opens in the window.
+
+== Operations that do not respond
+
+A server that stops responding can keep an operation running after you stop it, until the connection's own timeout ends it.
+When more than 16 operations of one explorer are still running, a new one is refused with a message.
+Check the connection, or wait for the running operations to end.
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/BrowserFileTransferFacadeImpl.java b/rap/src/main/java/org/apache/hop/ui/hopgui/BrowserFileTransferFacadeImpl.java
new file mode 100644
index 0000000000..a3763d5b72
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/BrowserFileTransferFacadeImpl.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.service.UISession;
+
+/** Browser uploads and downloads for Hop Web, with one transfer per user session. */
+public class BrowserFileTransferFacadeImpl extends BrowserFileTransferFacade {
+
+  private static final String SESSION_TRANSFER =
+      BrowserFileTransferFacadeImpl.class.getName() + ".transfer";
+
+  @Override
+  boolean isSupportedInternal() {
+    return true;
+  }
+
+  @Override
+  void uploadInternal(long sizeLimit, IUploadListener listener) {
+    UserFileTransfer transfer;
+    try {
+      transfer = transfer();
+    } catch (Exception e) {
+      listener.error(e);
+      return;
+    }
+    transfer.open(
+        null,
+        sizeLimit,
+        new UserFileTransfer.UploadListener() {
+          @Override
+          public void uploaded(String filename, Path uploadedFile) throws Exception {
+            // The transfer deletes its upload folder when this returns. Hand the listener a copy
+            // it owns, so it can store the file in the background.
+            Path owned =
+                Files.createTempFile(
+                    HopWebUserFilePlugin.getSessionTempDirectory(), "upload-", ".tmp");
+            Files.move(uploadedFile, owned, StandardCopyOption.REPLACE_EXISTING);
+            listener.uploaded(filename, owned);
+          }
+
+          @Override
+          public void error(Exception exception) {
+            listener.error(exception);
+          }
+        });
+  }
+
+  @Override
+  Path createTempFileInternal(String prefix) throws IOException {
+    return Files.createTempFile(HopWebUserFilePlugin.getSessionTempDirectory(), prefix, ".tmp");
+  }
+
+  @Override
+  void downloadInternal(String filename, Path source) throws IOException {
+    transfer().download(filename, "application/octet-stream", source);
+  }
+
+  private static UserFileTransfer transfer() throws IOException {
+    UISession session = RWT.getUISession();
+    Object existing = session.getAttribute(SESSION_TRANSFER);
+    if (existing instanceof UserFileTransfer transfer && !transfer.isDisposed()) {
+      return transfer;
+    }
+    UserFileTransfer transfer =
+        new UserFileTransfer(
+            HopGui.getInstance().getShell(), HopWebUserFilePlugin.getSessionTempDirectory());
+    session.setAttribute(SESSION_TRANSFER, transfer);
+    return transfer;
+  }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java
index 22b54814e7..207e2c5237 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java
@@ -628,7 +628,7 @@ public class HopWebUserFilePlugin {
 
   // Files.createTempDirectory creates the folder with owner-only (0700) permissions on POSIX
   @SuppressWarnings("java:S5443")
-  private static Path getSessionTempDirectory() throws IOException {
+  static Path getSessionTempDirectory() throws IOException {
     UISession session = RWT.getUISession();
     Path directory = (Path) session.getAttribute(SESSION_TEMP_DIRECTORY);
     if (directory != null) {
diff --git a/rcp/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerTest.java b/rcp/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerTest.java
new file mode 100644
index 0000000000..d2aa32ad53
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.vfs.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.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Drives a real VFS File Explorer through the paths that change files: a paste onto an existing
+ * file, and deleting the folder that is being shown.
+ */
+@Tag("uitest")
+class VfsFileExplorerTest extends SwtBotTestBase {
+
+  private static final long WAIT_MILLIS = 20_000;
+
+  @TempDir private Path folder;
+
+  @Test
+  void pasteOntoAnExistingFileAsksAndSkipKeepsTheOriginal() throws Exception {
+    Path source = Files.createDirectory(folder.resolve("source"));
+    Path target = Files.createDirectory(folder.resolve("target"));
+    Files.writeString(source.resolve("data.csv"), "new");
+    Files.writeString(target.resolve("data.csv"), "old");
+
+    AtomicReference<VfsFileExplorer> explorer = new AtomicReference<>();
+    withScene(
+        shell -> explorer.set(createExplorer(shell, source)),
+        bot -> {
+          VfsFileExplorerLocation location = onUi(() -> explorer.get().activeLocation());
+          waitForRows(location, 1);
+          onUi(
+              () -> {
+                location.fileTable().setSelection(0);
+                location.copySelectedFiles();
+                location.navigateTo(target.toString(), true);
+                return null;
+              });
+          waitUntilIdle(explorer.get());
+
+          display.asyncExec(location::pasteIntoFileList);
+          answerConflict(bot, "VfsFileExplorer.Conflict.Skip");
+          waitUntilIdle(explorer.get());
+          assertEquals("old", read(target.resolve("data.csv")));
+
+          display.asyncExec(location::pasteIntoFileList);
+          answerConflict(bot, "VfsFileExplorer.Conflict.Replace");
+          waitUntilIdle(explorer.get());
+          assertEquals("new", read(target.resolve("data.csv")));
+          assertEquals("new", read(source.resolve("data.csv")));
+        });
+
+    try (var files = Files.list(target)) {
+      assertEquals(1, files.count(), "No temporary file is left behind");
+    }
+  }
+
+  @Test
+  void deletingTheShownFolderOpensItsParent() throws Exception {
+    Path shown = Files.createDirectory(folder.resolve("shown"));
+    Files.writeString(shown.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
+
+    AtomicReference<VfsFileExplorer> explorer = new AtomicReference<>();
+    withScene(
+        shell -> explorer.set(createExplorer(shell, shown)),
+        bot -> {
+          VfsFileExplorerLocation location = onUi(() -> explorer.get().activeLocation());
+          waitForRows(location, 1);
+          onUi(
+              () -> {
+                location.folderTree().setSelection(location.folderTree().getItem(0));
+                return null;
+              });
+
+          display.asyncExec(location::deleteSelectedFolders);
+          SWTBotShell confirm =
+              bot.shell(
+                  BaseMessages.getString(VfsFileExplorer.PKG, "VfsFileExplorer.Delete.Title"));
+          confirm.bot().button(BaseMessages.getString("System.Button.Yes")).click();
+          waitUntilIdle(explorer.get());
+          waitFor(
+              () ->
+                  onUi(() -> VfsLocations.sameLocation(location.shownFolder(), folder.toString())),
+              "the explorer to show the parent folder");
+        });
+
+    assertFalse(Files.exists(shown));
+  }
+
+  private static VfsFileExplorer createExplorer(
+      org.eclipse.swt.widgets.Shell shell, Path location) {
+    shell.setLayout(new FormLayout());
+    shell.setSize(1000, 700);
+    VfsFileExplorer explorer = new VfsFileExplorer(shell, null);
+    explorer.setLayoutData(new FormDataBuilder().fullSize().result());
+    explorer.openLocation(location.toString());
+    return explorer;
+  }
+
+  private static void answerConflict(SWTBot bot, String buttonKey) {
+    SWTBotShell dialog =
+        bot.shell(BaseMessages.getString(VfsFileExplorer.PKG, "VfsFileExplorer.Conflict.Title"));
+    dialog.bot().button(BaseMessages.getString(VfsFileExplorer.PKG, buttonKey)).click();
+  }
+
+  private static void waitForRows(VfsFileExplorerLocation location, int rows) {
+    waitFor(
+        () -> {
+          Table table = onUi(location::fileTable);
+          return onUi(table::getItemCount) >= rows;
+        },
+        rows + " listed rows");
+  }
+
+  /** Every listing and file change has finished. */
+  private static void waitUntilIdle(VfsFileExplorer explorer) {
+    waitFor(() -> onUi(explorer::runningOperations) == 0, "the explorer operations to finish");
+    // A finished change lists the folder again from the UI thread.
+    onUi(() -> null);
+    waitFor(() -> onUi(explorer::runningOperations) == 0, "the folder to be listed again");
+  }
+
+  private static void waitFor(Supplier<Boolean> condition, String what) {
+    long deadline = System.currentTimeMillis() + WAIT_MILLIS;
+    while (!condition.get()) {
+      assertTrue(System.currentTimeMillis() < deadline, "Timed out waiting for " + what);
+      try {
+        Thread.sleep(50);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new IllegalStateException(e);
+      }
+    }
+  }
+
+  private static String read(Path file) {
+    try {
+      return Files.readString(file);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  private static <T> T onUi(Supplier<T> supplier) {
+    AtomicReference<T> result = new AtomicReference<>();
+    display.syncExec(() -> result.set(supplier.get()));
+    return result.get();
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/BrowserFileTransferFacade.java b/ui/src/main/java/org/apache/hop/ui/hopgui/BrowserFileTransferFacade.java
new file mode 100644
index 0000000000..d6f5de44ec
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/BrowserFileTransferFacade.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Moves a file between the user's browser and the server, for Hop Web. On the desktop there is no
+ * browser, and {@link #isSupported()} is false.
+ */
+public abstract class BrowserFileTransferFacade {
+
+  /** Receives a file the user uploaded from the browser. */
+  public interface IUploadListener {
+    /**
+     * @param filename the name of the file on the user's computer, without a folder
+     * @param uploadedFile a server-side temporary copy. The listener owns it and deletes it.
+     */
+    void uploaded(String filename, Path uploadedFile) throws Exception;
+
+    void error(Exception exception);
+  }
+
+  private static final BrowserFileTransferFacade IMPL = load();
+
+  private static BrowserFileTransferFacade load() {
+    try {
+      return (BrowserFileTransferFacade)
+          ImplementationLoader.newInstance(BrowserFileTransferFacade.class);
+    } catch (Throwable e) {
+      // The desktop and the hop-ui unit tests have no browser to transfer to.
+      return new BrowserFileTransferFacade() {
+        @Override
+        boolean isSupportedInternal() {
+          return false;
+        }
+
+        @Override
+        void uploadInternal(long sizeLimit, IUploadListener listener) {
+          listener.error(new IOException("There is no browser to upload from."));
+        }
+
+        @Override
+        void downloadInternal(String filename, Path source) throws IOException {
+          throw new IOException("There is no browser to download to.");
+        }
+      };
+    }
+  }
+
+  /**
+   * A new temporary file for a download. On Hop Web it is in the session's temporary folder, which
+   * is removed when the session ends.
+   */
+  public static Path createTempFile(String prefix) throws IOException {
+    return IMPL.createTempFileInternal(prefix);
+  }
+
+  /** True on Hop Web. */
+  public static boolean isSupported() {
+    return IMPL.isSupportedInternal();
+  }
+
+  /**
+   * Let the user choose a file in the browser and upload it. Call on the UI thread. Returns once
+   * the upload finished or was cancelled.
+   *
+   * @param sizeLimit the largest file accepted, in bytes
+   * @param listener receives the uploaded file or the error
+   */
+  public static void upload(long sizeLimit, IUploadListener listener) {
+    IMPL.uploadInternal(sizeLimit, listener);
+  }
+
+  /**
+   * Offer {@code source} to the browser as a download named {@code filename}. Call on the UI
+   * thread. The content is copied, so the caller may delete {@code source} afterwards.
+   */
+  public static void download(String filename, Path source) throws IOException {
+    IMPL.downloadInternal(filename, source);
+  }
+
+  abstract boolean isSupportedInternal();
+
+  Path createTempFileInternal(String prefix) throws IOException {
+    return Files.createTempFile(prefix, ".tmp");
+  }
+
+  abstract void uploadInternal(long sizeLimit, IUploadListener listener);
+
+  abstract void downloadInternal(String filename, Path source) throws IOException;
+}
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 3ea922c7be..3c65c7d456 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
@@ -1129,6 +1129,12 @@ public class ExplorerPerspective implements IHopPerspective, TabClosable, IFileD
             // Guard every precondition: uncaught exceptions in RAP DND listeners can tear down
             // the Hop Web UI session (see #7933).
             try {
+              // A drop writes, and a move also deletes the source. The VFS File Explorer drags
+              // files from anywhere, so the perspective checks the permission itself.
+              if (!HopSecurityUi.check(Permission.EXPLORER_WRITE)) {
+                event.detail = DND.DROP_NONE;
+                return;
+              }
               if (!FileTransfer.getInstance().isSupportedType(event.currentDataType)
                   || event.item == null
                   || event.data == null
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperation.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperation.java
index 00026fa99a..aa18f8c748 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperation.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperation.java
@@ -17,8 +17,11 @@
 
 package org.apache.hop.ui.hopgui.vfs.explorer;
 
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
 import lombok.Getter;
 import org.apache.hop.core.Const;
+import org.apache.hop.i18n.BaseMessages;
 
 /** One listing or file change. Stop interrupts the worker; it does not close the file system. */
 @Getter
@@ -42,6 +45,11 @@ public class VfsExplorerOperation {
   private volatile boolean cancelled;
   private volatile Thread worker;
 
+  /** Files and bytes a copy or move wrote so far. */
+  private final AtomicInteger filesDone = new AtomicInteger();
+
+  private final AtomicLong bytesDone = new AtomicLong();
+
   public VfsExplorerOperation(String description, String location) {
     this.description = description == null ? "" : description;
     this.location = location == null ? "" : location;
@@ -62,6 +70,12 @@ public class VfsExplorerOperation {
     }
   }
 
+  /** The worker itself stopped, for example on Cancel in a prompt. Does not interrupt. */
+  void cancelFromWorker() {
+    cancelled = true;
+    complete();
+  }
+
   public boolean isCancelled() {
     return cancelled;
   }
@@ -155,6 +169,43 @@ public class VfsExplorerOperation {
     return text == null || text.isBlank();
   }
 
+  /** Progress for {@link VfsFileTransfer}, counted on this operation. */
+  public VfsFileTransfer.Progress progress() {
+    return new VfsFileTransfer.Progress() {
+      @Override
+      public void bytes(long count) {
+        bytesDone.addAndGet(count);
+      }
+
+      @Override
+      public void fileDone() {
+        filesDone.incrementAndGet();
+      }
+    };
+  }
+
+  public int getFilesDone() {
+    return filesDone.get();
+  }
+
+  public long getBytesDone() {
+    return bytesDone.get();
+  }
+
+  /** {@code 3 files - 120MB}, or empty when nothing was written. */
+  public String progressText() {
+    int files = filesDone.get();
+    long bytes = bytesDone.get();
+    if (files == 0 && bytes == 0) {
+      return "";
+    }
+    return BaseMessages.getString(
+        VfsFileExplorer.PKG,
+        "VfsFileExplorer.Operations.Progress",
+        files,
+        VfsListingCounts.formatSize(bytes));
+  }
+
   public boolean isFinished() {
     return status != Status.RUNNING;
   }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperationsPanel.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperationsPanel.java
index e6b8279876..d041479f90 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperationsPanel.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerOperationsPanel.java
@@ -67,7 +67,6 @@ public class VfsExplorerOperationsPanel extends Composite {
 
   static final int MAX_FINISHED_OPERATIONS = 50;
 
-  private final VfsFileExplorer explorer;
   private final List<VfsExplorerOperation> operations = new ArrayList<>();
   private VfsListingCounts listingCounts = VfsListingCounts.NONE;
   private final Table table;
@@ -80,10 +79,8 @@ public class VfsExplorerOperationsPanel extends Composite {
   private boolean expanded;
   private Consumer<Boolean> expandedListener;
 
-  public VfsExplorerOperationsPanel(
-      Composite sashParent, Composite statusParent, VfsFileExplorer explorer) {
+  public VfsExplorerOperationsPanel(Composite sashParent, Composite statusParent) {
     super(sashParent, SWT.NONE);
-    this.explorer = explorer;
     PropsUi.setLook(this);
     setLayout(new FormLayout());
 
@@ -204,6 +201,16 @@ public class VfsExplorerOperationsPanel extends Composite {
     armTimer();
   }
 
+  public int runningCount() {
+    int running = 0;
+    for (VfsExplorerOperation operation : operations) {
+      if (!operation.isFinished()) {
+        running++;
+      }
+    }
+    return running;
+  }
+
   public void cancelRunning() {
     for (VfsExplorerOperation operation : operations) {
       if (!operation.isFinished()) {
@@ -219,10 +226,16 @@ public class VfsExplorerOperationsPanel extends Composite {
       toolTip = "i18n::VfsFileExplorer.Operations.Stop.Tooltip",
       image = "ui/images/stop.svg")
   public void stopSelected() {
-    VfsExplorerOperation selected = selectedOperation();
-    if (selected != null && !selected.isFinished()) {
-      explorer.cancelListings();
+    stop(selectedOperation());
+  }
+
+  /** Stop one operation. Others, including copies in other tabs, keep running. */
+  private void stop(VfsExplorerOperation operation) {
+    if (operation == null || operation.isFinished()) {
+      return;
     }
+    operation.cancel();
+    refresh();
   }
 
   @GuiToolbarElement(
@@ -252,9 +265,7 @@ public class VfsExplorerOperationsPanel extends Composite {
       toolTip = "i18n::VfsFileExplorer.Operations.Stop.Tooltip",
       image = "ui/images/stop.svg")
   public void stopCurrent() {
-    if (currentOperation() != null) {
-      explorer.cancelListings();
-    }
+    stop(currentOperation());
   }
 
   @GuiToolbarElement(
@@ -299,6 +310,7 @@ public class VfsExplorerOperationsPanel extends Composite {
     boolean anyRunning = false;
     for (int i = 0; i < operations.size() && i < table.getItemCount(); i++) {
       VfsExplorerOperation operation = operations.get(i);
+      table.getItem(i).setText(2, statusLabel(operation));
       table.getItem(i).setText(3, formatElapsed(operation.elapsedMillis()));
       if (!operation.isFinished()) {
         anyRunning = true;
@@ -350,7 +362,14 @@ public class VfsExplorerOperationsPanel extends Composite {
     return operations.isEmpty() ? null : operations.get(0);
   }
 
+  /** The status, with the files and bytes written so far for a copy or move. */
   static String statusLabel(VfsExplorerOperation operation) {
+    String progress = operation.progressText();
+    String status = statusName(operation);
+    return progress.isEmpty() ? status : status + " (" + progress + ")";
+  }
+
+  private static String statusName(VfsExplorerOperation operation) {
     return switch (operation.getStatus()) {
       case RUNNING -> BaseMessages.getString(PKG, "VfsFileExplorer.Operations.Status.Running");
       case DONE -> BaseMessages.getString(PKG, "VfsFileExplorer.Operations.Status.Done");
@@ -376,6 +395,10 @@ public class VfsExplorerOperationsPanel extends Composite {
     line.append(Const.NVL(operation.getDescription(), ""));
     line.append(" - ").append(statusLabel(operation));
     line.append(" - ").append(formatStatusElapsed(operation.elapsedMillis()));
+    String progress = operation.progressText();
+    if (!progress.isEmpty()) {
+      line.append(" - ").append(progress);
+    }
     if (counts != null) {
       line.append(counts.statusSuffix());
     }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileClipboard.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileClipboard.java
index db1734352e..3846e9192b 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileClipboard.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileClipboard.java
@@ -19,38 +19,73 @@ package org.apache.hop.ui.hopgui.vfs.explorer;
 
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.swt.widgets.Display;
 
 /**
  * Files and folders copied or cut inside the VFS File Explorer. This is not the operating-system
- * clipboard, so text in a location field is left alone. One clipboard is shared by every explorer
- * in the session, including the window and the bottom panel.
+ * clipboard, so text in a location field is left alone. The window and the bottom panel share one
+ * clipboard. It is kept on the display: Hop Web has a display per session, so one user's cut files
+ * never show up in another user's paste.
  */
 public final class VfsFileClipboard {
 
-  private static List<VfsFileTransfer.Entry> entries = List.of();
-  private static VfsFileTransfer.Mode mode = VfsFileTransfer.Mode.COPY;
+  static final String DISPLAY_DATA_KEY = VfsFileClipboard.class.getName();
 
-  private VfsFileClipboard() {}
+  private List<VfsFileTransfer.Entry> entries = List.of();
+  private VfsFileTransfer.Mode mode = VfsFileTransfer.Mode.COPY;
 
-  public static synchronized void set(
-      VfsFileTransfer.Mode mode, List<VfsFileTransfer.Entry> items) {
-    VfsFileClipboard.mode = mode == null ? VfsFileTransfer.Mode.COPY : mode;
+  VfsFileClipboard() {}
+
+  /**
+   * The clipboard of {@code display}. Call on the UI thread. Without a display a new, unshared
+   * clipboard is returned.
+   */
+  public static VfsFileClipboard of(Display display) {
+    if (display == null || display.isDisposed()) {
+      return new VfsFileClipboard();
+    }
+    Object existing = display.getData(DISPLAY_DATA_KEY);
+    if (existing instanceof VfsFileClipboard clipboard) {
+      return clipboard;
+    }
+    VfsFileClipboard clipboard = new VfsFileClipboard();
+    display.setData(DISPLAY_DATA_KEY, clipboard);
+    return clipboard;
+  }
+
+  public synchronized void set(VfsFileTransfer.Mode mode, List<VfsFileTransfer.Entry> items) {
+    this.mode = mode == null ? VfsFileTransfer.Mode.COPY : mode;
     entries = List.copyOf(VfsFileTransfer.withoutNested(items));
   }
 
-  public static synchronized List<VfsFileTransfer.Entry> entries() {
+  public synchronized List<VfsFileTransfer.Entry> entries() {
     return new ArrayList<>(entries);
   }
 
-  public static synchronized VfsFileTransfer.Mode mode() {
+  public synchronized VfsFileTransfer.Mode mode() {
     return mode;
   }
 
-  public static synchronized boolean isEmpty() {
+  public synchronized boolean isEmpty() {
     return entries.isEmpty();
   }
 
-  public static synchronized void clear() {
+  /**
+   * After a paste of {@code pasted}, keep only {@code remaining}: the moved files are no longer
+   * where the clipboard points. Left alone when something else was copied or cut in the meantime.
+   */
+  public synchronized void retain(
+      List<VfsFileTransfer.Entry> pasted, List<VfsFileTransfer.Entry> remaining) {
+    if (!entries.equals(pasted)) {
+      return;
+    }
+    entries = remaining == null ? List.of() : List.copyOf(remaining);
+    if (entries.isEmpty()) {
+      mode = VfsFileTransfer.Mode.COPY;
+    }
+  }
+
+  public synchronized void clear() {
     entries = List.of();
     mode = VfsFileTransfer.Mode.COPY;
   }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorer.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorer.java
index 10498c3930..35cb92ec6b 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorer.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorer.java
@@ -107,14 +107,13 @@ public class VfsFileExplorer extends Composite {
     tabFolder.addListener(
         SWT.Selection,
         e -> {
-          activate();
           VfsFileExplorerLocation location = activeLocation();
           if (location != null) {
             location.publishCounts();
           }
         });
 
-    operationsPanel = new VfsExplorerOperationsPanel(sash, this, this);
+    operationsPanel = new VfsExplorerOperationsPanel(sash, this);
     operationsPanel.setExpandedListener(this::layoutOperations);
     sash.setWeights(80, 20);
     layoutOperations(false);
@@ -129,13 +128,15 @@ public class VfsFileExplorer extends Composite {
         });
   }
 
-  /** Open {@code location}, or the user home folder when it is blank. Focus an existing tab. */
+  /** Open {@code location}, or the project or user home when it is blank. Focus an existing tab. */
   public void openLocation(String location) {
     String target =
-        StringUtils.isBlank(location) ? System.getProperty("user.home") : location.trim();
+        StringUtils.isBlank(location)
+            ? VfsFileExplorerViews.defaultLocation(variables)
+            : location.trim();
     for (CTabItem item : tabFolder.getItems()) {
       if (item.getData() instanceof VfsFileExplorerLocation tab
-          && sameLocation(tab.getLocationText(), target)) {
+          && VfsLocations.sameLocation(tab.getLocationText(), target)) {
         tabFolder.setSelection(item);
         return;
       }
@@ -154,14 +155,18 @@ public class VfsFileExplorer extends Composite {
     tab.navigateTo(target, true);
   }
 
-  public void activate() {
-    VfsFileExplorers.activate(this);
-  }
-
   public Map<String, String> bookmarks() {
     return bookmarks;
   }
 
+  /** Unfinished operations a host may have before it refuses new ones. */
+  public static final int MAX_RUNNING_OPERATIONS = 16;
+
+  /** Operations whose worker has not returned yet, including stopped ones still blocked in I/O. */
+  public int runningOperations() {
+    return operationsPanel.isDisposed() ? 0 : operationsPanel.runningCount();
+  }
+
   public VfsExplorerOperation beginOperation(String description, String location) {
     return operationsPanel.addOperation(description, location);
   }
@@ -215,7 +220,7 @@ public class VfsFileExplorer extends Composite {
     VfsFileExplorerLocation location = activeLocation();
     String target = location == null ? "" : location.shownFolder();
     if (StringUtils.isBlank(target)) {
-      target = System.getProperty("user.home");
+      target = VfsFileExplorerViews.defaultLocation(variables);
     }
     newTab(target);
   }
@@ -263,6 +268,56 @@ public class VfsFileExplorer extends Composite {
     }
   }
 
+  @GuiKeyboardShortcut(key = SWT.DEL)
+  @GuiOsxKeyboardShortcut(key = SWT.DEL)
+  public void deleteActive() {
+    withActive(VfsFileExplorerLocation::deleteFromShortcut);
+  }
+
+  @GuiKeyboardShortcut(control = true, key = 'c')
+  @GuiOsxKeyboardShortcut(command = true, key = 'c')
+  public void copyActive() {
+    withActive(VfsFileExplorerLocation::copyFromShortcut);
+  }
+
+  @GuiKeyboardShortcut(control = true, key = 'x')
+  @GuiOsxKeyboardShortcut(command = true, key = 'x')
+  public void cutActive() {
+    withActive(VfsFileExplorerLocation::cutFromShortcut);
+  }
+
+  @GuiKeyboardShortcut(control = true, key = 'v')
+  @GuiOsxKeyboardShortcut(command = true, key = 'v')
+  public void pasteActive() {
+    withActive(VfsFileExplorerLocation::pasteClipboard);
+  }
+
+  /** Backspace in the tree or the file list. In the location field it still deletes text. */
+  @GuiKeyboardShortcut(key = SWT.BS)
+  @GuiOsxKeyboardShortcut(key = SWT.BS)
+  public void upActive() {
+    withActive(VfsFileExplorerLocation::navigateUp);
+  }
+
+  @GuiKeyboardShortcut(alt = true, key = SWT.ARROW_UP)
+  @GuiOsxKeyboardShortcut(command = true, key = SWT.ARROW_UP)
+  public void upActiveWithArrow() {
+    withActive(VfsFileExplorerLocation::navigateUp);
+  }
+
+  @GuiKeyboardShortcut(control = true, key = 'l')
+  @GuiOsxKeyboardShortcut(command = true, key = 'l')
+  public void focusLocationActive() {
+    withActive(VfsFileExplorerLocation::focusLocation);
+  }
+
+  private void withActive(java.util.function.Consumer<VfsFileExplorerLocation> action) {
+    VfsFileExplorerLocation location = activeLocation();
+    if (location != null) {
+      action.accept(location);
+    }
+  }
+
   private void layoutOperations(boolean expanded) {
     if (sash.isDisposed()) {
       return;
@@ -288,7 +343,7 @@ public class VfsFileExplorer extends Composite {
     layout(true, true);
   }
 
-  private VfsFileExplorerLocation activeLocation() {
+  VfsFileExplorerLocation activeLocation() {
     if (tabFolder.isDisposed()) {
       return null;
     }
@@ -308,7 +363,7 @@ public class VfsFileExplorer extends Composite {
   private void refreshBookmarkLists() {
     for (CTabItem item : tabFolder.getItems()) {
       if (item.getData() instanceof VfsFileExplorerLocation location) {
-        location.refreshBookmarks();
+        location.refreshPlaces();
       }
     }
   }
@@ -324,19 +379,4 @@ public class VfsFileExplorer extends Composite {
       refreshOperations();
     }
   }
-
-  static boolean sameLocation(String left, String right) {
-    return stripTrailingSlash(left).equals(stripTrailingSlash(right));
-  }
-
-  private static String stripTrailingSlash(String value) {
-    if (value == null) {
-      return "";
-    }
-    String stripped = value;
-    while (stripped.length() > 1 && (stripped.endsWith("/") || stripped.endsWith("\\"))) {
-      stripped = stripped.substring(0, stripped.length() - 1);
-    }
-    return stripped;
-  }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerDialog.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerDialog.java
index 66a2dd9dea..0bdaa53998 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerDialog.java
@@ -18,11 +18,13 @@
 package org.apache.hop.ui.hopgui.vfs.explorer;
 
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.security.Permission;
 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.gui.GuiResource;
 import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.security.HopSecurityUi;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
 import org.eclipse.swt.SWT;
@@ -56,7 +58,10 @@ public class VfsFileExplorerDialog {
     openAt(hopGui, null);
   }
 
-  /** Open or focus the window and show {@code location}. A blank location uses the user home. */
+  /**
+   * Open or focus the window and show {@code location}. A blank location uses the project or user
+   * home.
+   */
   public static void openAt(HopGui hopGui, String location) {
     VfsFileExplorerDialog dialog = openOrCreate(hopGui);
     if (dialog == null || dialog.explorer == null || dialog.explorer.isDisposed()) {
@@ -72,6 +77,9 @@ public class VfsFileExplorerDialog {
     if (hopGui == null || hopGui.getShell() == null || hopGui.getShell().isDisposed()) {
       return null;
     }
+    if (!HopSecurityUi.check(Permission.FILE_VIEW)) {
+      return null;
+    }
     Object existing = hopGui.getShell().getData(SHELL_DATA_KEY);
     if (existing instanceof VfsFileExplorerDialog dialog && dialog.isOpen()) {
       dialog.activate();
@@ -94,9 +102,6 @@ public class VfsFileExplorerDialog {
     shell.setMinimized(false);
     shell.setActive();
     shell.forceActive();
-    if (explorer != null && !explorer.isDisposed()) {
-      explorer.activate();
-    }
   }
 
   private void openShell() {
@@ -112,7 +117,6 @@ public class VfsFileExplorerDialog {
     explorer = new VfsFileExplorer(shell, hopGui);
     explorer.setLayoutData(new FormDataBuilder().fullSize().result());
     explorer.openLocation(null);
-    explorer.activate();
 
     HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
     keyHandler.addParentObjectToHandle(explorer);
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java
index f32960ed66..2bb258ccae 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java
@@ -17,33 +17,40 @@
 
 package org.apache.hop.ui.hopgui.vfs.explorer;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.CancellationException;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileObject;
-import org.apache.commons.vfs2.Selectors;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
 import org.apache.hop.core.logging.HopLogStore;
 import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.security.HopSecurity;
+import org.apache.hop.core.security.Permission;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.ConstUi;
 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.DialogBoxWithButtons;
 import org.apache.hop.ui.core.dialog.EnterStringDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.security.HopSecurityUi;
 import org.apache.hop.ui.core.vfs.HopVfsFileDialog;
 import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.BrowserFileTransferFacade;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
@@ -52,9 +59,19 @@ import org.apache.hop.ui.hopgui.perspective.explorer.file.types.FolderFileType;
 import org.apache.hop.ui.hopgui.perspective.explorer.file.types.GenericFileType;
 import org.apache.hop.ui.hopgui.perspective.explorer.file.types.raw.RawExplorerFileType;
 import org.apache.hop.ui.hopgui.shared.SashFormMemory;
+import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabItem;
 import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.DragSource;
+import org.eclipse.swt.dnd.DragSourceAdapter;
+import org.eclipse.swt.dnd.DragSourceEvent;
+import org.eclipse.swt.dnd.DropTarget;
+import org.eclipse.swt.dnd.DropTargetAdapter;
+import org.eclipse.swt.dnd.DropTargetEvent;
+import org.eclipse.swt.dnd.FileTransfer;
+import org.eclipse.swt.dnd.Transfer;
 import org.eclipse.swt.graphics.Image;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
@@ -72,7 +89,9 @@ import org.eclipse.swt.widgets.TableColumn;
 import org.eclipse.swt.widgets.TableItem;
 import org.eclipse.swt.widgets.Text;
 import org.eclipse.swt.widgets.ToolItem;
+import org.eclipse.swt.widgets.Tree;
 import org.eclipse.swt.widgets.TreeItem;
+import org.eclipse.swt.widgets.Widget;
 
 /**
  * One location tab: lazy folder tree, file details, filter and bookmarks. Listings run off the UI
@@ -112,21 +131,32 @@ public class VfsFileExplorerLocation extends Composite {
   private static final String DETAILS_PASTE = "VfsFileExplorer-Details-0060-Paste";
   private static final String DETAILS_RENAME = "VfsFileExplorer-Details-0070-Rename";
   private static final String DETAILS_DELETE = "VfsFileExplorer-Details-0100-Delete";
+  private static final String DETAILS_MORE = "VfsFileExplorer-Details-0150-More";
   private static final String DETAILS_HIDDEN = "VfsFileExplorer-Details-0160-Hidden";
   private static final String DETAILS_COLUMNS = "VfsFileExplorer-Details-0200-Columns";
 
   private static final String BOOKMARK_ADD = "VfsFileExplorer-Bookmarks-0010-Add";
   private static final String BOOKMARK_REMOVE = "VfsFileExplorer-Bookmarks-0030-Remove";
+  private static final String PLACES_REFRESH = "VfsFileExplorer-Bookmarks-0100-Refresh";
+
+  private static final String PROJECT_HOME = "PROJECT_HOME";
+
+  /** The largest file a Hop Web user can upload into a folder. */
+  private static final long BROWSER_UPLOAD_LIMIT = 512L * 1024 * 1024;
+
+  /** Typing in the filter waits this long before the table is filled again. */
+  private static final int FILTER_DELAY_MS = 250;
 
   private final VfsFileExplorer explorer;
   private final IVariables variables;
+  private final Runnable delayedRefill = this::refillTableIfAlive;
   private final VfsListingGeneration generation = new VfsListingGeneration();
   private final List<String> history = new ArrayList<>();
   private final TextVar locationText;
   private final Text filterText;
   private final HopTree tree;
   private final Table table;
-  private final org.eclipse.swt.widgets.List bookmarksList;
+  private final Tree placesTree;
   private final GuiToolbarWidgets navigateToolbar;
   private final GuiToolbarWidgets treeToolbar;
   private final GuiToolbarWidgets detailsToolbar;
@@ -142,6 +172,9 @@ public class VfsFileExplorerLocation extends Composite {
   private FolderNode shown;
   private List<VfsFileRow> shownRows = List.of();
 
+  /** The paths of the dragged selection. Set when the drag starts, read when it is dropped here. */
+  private List<String> draggedUris = List.of();
+
   public VfsFileExplorerLocation(Composite parent, VfsFileExplorer explorer) {
     super(parent, SWT.NONE);
     this.explorer = explorer;
@@ -228,7 +261,6 @@ public class VfsFileExplorerLocation extends Composite {
         SWT.Selection, e -> onTreeSelected(e.item instanceof TreeItem treeItem ? treeItem : null));
     tree.addListener(SWT.Expand, e -> onTreeExpand((TreeItem) e.item));
     tree.addListener(SWT.MenuDetect, e -> showFolderMenu());
-    tree.addListener(SWT.FocusIn, e -> explorer.activate());
 
     Composite bookmarksComposite = new Composite(left, SWT.NONE);
     bookmarksComposite.setLayout(new FormLayout());
@@ -243,16 +275,19 @@ public class VfsFileExplorerLocation extends Composite {
     bookmarksToolbar.registerGuiPluginObject(this);
     bookmarksToolbar.createToolbarWidgets(bookmarkBar, BOOKMARKS_TOOLBAR_PARENT_ID);
     bookmarkBarControl.pack();
-    bookmarksList =
-        new org.eclipse.swt.widgets.List(
-            bookmarksComposite, SWT.SINGLE | SWT.V_SCROLL | SWT.H_SCROLL | SWT.BORDER);
-    PropsUi.setLook(bookmarksList);
-    bookmarksList.setLayoutData(
+    placesTree =
+        new Tree(bookmarksComposite, SWT.SINGLE | SWT.V_SCROLL | SWT.H_SCROLL | SWT.BORDER);
+    PropsUi.setLook(placesTree);
+    placesTree.setLayoutData(
         new FormDataBuilder().top(bookmarkBarControl, 0).bottom().fullWidth().result());
-    bookmarksList.addListener(SWT.DefaultSelection, e -> openSelectedBookmark());
-    bookmarksList.addListener(SWT.Selection, e -> updateToolbar());
-    left.setWeights(75, 25);
-    SashFormMemory.persist(left, "vfs-explorer-bookmarks-height", 75, 25);
+    placesTree.addListener(
+        SWT.Selection,
+        e -> {
+          updateToolbar();
+          openPlace(e.item);
+        });
+    left.setWeights(65, 35);
+    SashFormMemory.persist(left, "vfs-explorer-bookmarks-height", 65, 35);
 
     Composite details = new Composite(horizontal, SWT.NONE);
     details.setLayout(new FormLayout());
@@ -265,7 +300,12 @@ public class VfsFileExplorerLocation extends Composite {
     filterText.setMessage(BaseMessages.getString(PKG, "VfsFileExplorer.Filter.Placeholder"));
     PropsUi.setLook(filterText, PropsUi.WIDGET_STYLE_TOOLBAR);
     filterText.setLayoutData(new FormDataBuilder().right().top().width(220).result());
-    filterText.addListener(SWT.Modify, e -> refillTable());
+    filterText.addListener(
+        SWT.Modify,
+        e -> {
+          getDisplay().timerExec(-1, delayedRefill);
+          getDisplay().timerExec(FILTER_DELAY_MS, delayedRefill);
+        });
     IToolbarContainer detailsBar =
         ToolbarFacade.createToolbarContainer(detailsRow, SWT.WRAP | SWT.LEFT | SWT.HORIZONTAL);
     Control detailsBarControl = detailsBar.getControl();
@@ -275,25 +315,40 @@ public class VfsFileExplorerLocation extends Composite {
     detailsToolbar = new GuiToolbarWidgets();
     detailsToolbar.registerGuiPluginObject(this);
     detailsToolbar.createToolbarWidgets(detailsBar, DETAILS_TOOLBAR_PARENT_ID);
+    if (BrowserFileTransferFacade.isSupported()) {
+      detailsToolbar.setToolbarItemToolTip(
+          DETAILS_DOWNLOAD,
+          BaseMessages.getString(PKG, "VfsFileExplorer.Details.Download.Web.Tooltip"));
+      detailsToolbar.setToolbarItemToolTip(
+          DETAILS_UPLOAD,
+          BaseMessages.getString(PKG, "VfsFileExplorer.Details.Upload.Web.Tooltip"));
+    }
     detailsBarControl.pack();
 
     table =
         new Table(
-            details, SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI | SWT.H_SCROLL | SWT.V_SCROLL);
+            details,
+            SWT.BORDER
+                | SWT.FULL_SELECTION
+                | SWT.MULTI
+                | SWT.H_SCROLL
+                | SWT.V_SCROLL
+                | SWT.VIRTUAL);
     PropsUi.setLook(table);
     table.setHeaderVisible(true);
     table.setLinesVisible(false);
     table.setLayoutData(new FormDataBuilder().top(detailsRow, 0).bottom().fullWidth().result());
+    table.addListener(SWT.SetData, e -> fillItem((TableItem) e.item));
     table.addListener(SWT.Selection, e -> updateToolbar());
     table.addListener(SWT.DefaultSelection, e -> onTableDefaultSelection());
     table.addListener(SWT.MenuDetect, e -> showFileMenu());
-    table.addListener(SWT.FocusIn, e -> explorer.activate());
     rebuildColumns();
 
+    installDragAndDrop();
+
     horizontal.setWeights(28, 72);
     SashFormMemory.persist(horizontal, "vfs-explorer-tree-width", 28, 72);
 
-    addListener(SWT.FocusIn, e -> explorer.activate());
     addDisposeListener(
         e -> {
           invalidate();
@@ -302,10 +357,191 @@ public class VfsFileExplorerLocation extends Composite {
           detailsToolbar.dispose();
           bookmarksToolbar.dispose();
         });
-    refreshBookmarks();
+    refreshPlaces();
     updateToolbar();
   }
 
+  /**
+   * Drag folders from the tree and rows from the file list, and drop on a folder. The paths travel
+   * as a {@link FileTransfer}, as in the File Explorer perspective, so files dragged between the
+   * two or from the operating system are accepted too. A drop copies: the source and target are
+   * often different stores. Only the move modifier of the desktop platform moves, see {@link
+   * #dropOperation}.
+   */
+  private void installDragAndDrop() {
+    FileTransfer fileTransfer = FileTransfer.getInstance();
+    Transfer[] transfers = {fileTransfer};
+
+    DragSource treeSource = new DragSource(tree, DND.DROP_COPY | DND.DROP_MOVE);
+    treeSource.setTransfer(transfers);
+    treeSource.addDragListener(dragListener(this::selectedFolderEntries));
+    DragSource tableSource = new DragSource(table, DND.DROP_COPY | DND.DROP_MOVE);
+    tableSource.setTransfer(transfers);
+    tableSource.addDragListener(dragListener(this::selectedFileEntries));
+
+    // DROP_DEFAULT: without it GTK and Cocoa report a drag without a modifier as a move.
+    int dropStyle = DND.DROP_COPY | DND.DROP_MOVE | DND.DROP_DEFAULT;
+    DropTarget treeTarget = new DropTarget(tree, dropStyle);
+    treeTarget.setTransfer(transfers);
+    treeTarget.addDropListener(
+        dropListener(
+            event -> {
+              FolderNode node = event.item instanceof TreeItem item ? nodeOf(item) : null;
+              return node == null ? null : node.uri;
+            }));
+    DropTarget tableTarget = new DropTarget(table, dropStyle);
+    tableTarget.setTransfer(transfers);
+    tableTarget.addDropListener(
+        dropListener(
+            event -> {
+              // A folder row is the target. Anywhere else in the list is the folder being viewed.
+              if (event.item instanceof TableItem item && !item.isDisposed()) {
+                int index = table.indexOf(item);
+                if (index >= 0 && index < shownRows.size() && shownRows.get(index).isFolder()) {
+                  return shownRows.get(index).getUri();
+                }
+              }
+              return currentFolderUri();
+            }));
+  }
+
+  private DragSourceAdapter dragListener(
+      java.util.function.Supplier<List<VfsFileTransfer.Entry>> selection) {
+    return new DragSourceAdapter() {
+      @Override
+      public void dragStart(DragSourceEvent event) {
+        // A drop elsewhere, for example on the File Explorer perspective, writes or moves files.
+        List<String> uris = canWrite() ? urisOf(selection.get()) : List.of();
+        event.doit = !uris.isEmpty();
+        draggedUris = uris;
+        if (EnvironmentUtils.getInstance().isWeb()) {
+          // RAP has no native drag image to fall back on.
+          event.image = GuiResource.getInstance().getImageFolder();
+        }
+      }
+
+      @Override
+      public void dragSetData(DragSourceEvent event) {
+        // Read the selection again rather than the paths kept at drag start: Hop Web can finish
+        // the drag before it asks for the data, and dragFinished has cleared them by then.
+        if (FileTransfer.getInstance().isSupportedType(event.dataType)) {
+          List<String> uris = urisOf(selection.get());
+          event.doit = !uris.isEmpty();
+          event.data = uris.toArray(new String[0]);
+        }
+      }
+
+      @Override
+      public void dragFinished(DragSourceEvent event) {
+        draggedUris = List.of();
+      }
+    };
+  }
+
+  private DropTargetAdapter dropListener(
+      java.util.function.Function<DropTargetEvent, String> targetOf) {
+    return new DropTargetAdapter() {
+      @Override
+      public void dragEnter(DropTargetEvent event) {
+        event.detail =
+            dropOperation(event.detail, event.operations, EnvironmentUtils.getInstance().isWeb());
+      }
+
+      @Override
+      public void dragOperationChanged(DropTargetEvent event) {
+        dragEnter(event);
+      }
+
+      @Override
+      public void dragOver(DropTargetEvent event) {
+        event.feedback = DND.FEEDBACK_SELECT | DND.FEEDBACK_SCROLL | DND.FEEDBACK_EXPAND;
+        String target = targetOf.apply(event);
+        if (!canWrite() || StringUtils.isBlank(target) || !canDropInto(draggedUris, target)) {
+          event.detail = DND.DROP_NONE;
+        } else {
+          event.detail =
+              dropOperation(event.detail, event.operations, EnvironmentUtils.getInstance().isWeb());
+        }
+      }
+
+      @Override
+      public void drop(DropTargetEvent event) {
+        String target = targetOf.apply(event);
+        if (!(event.data instanceof String[] paths)
+            || paths.length == 0
+            || StringUtils.isBlank(target)
+            || !checkWrite()) {
+          event.detail = DND.DROP_NONE;
+          return;
+        }
+        // A folder and a path inside it are one transfer: the folder copy includes the rest.
+        List<String> uris = VfsFileTransfer.withoutNestedPaths(List.of(paths));
+        if (uris.isEmpty() || !canDropInto(uris, target)) {
+          event.detail = DND.DROP_NONE;
+          return;
+        }
+        List<VfsFileTransfer.Entry> entries = new ArrayList<>();
+        for (String uri : uris) {
+          entries.add(new VfsFileTransfer.Entry(uri, titleOf(uri), false));
+        }
+        int operation =
+            dropOperation(event.detail, event.operations, EnvironmentUtils.getInstance().isWeb());
+        VfsFileTransfer.Mode mode =
+            operation == DND.DROP_MOVE ? VfsFileTransfer.Mode.MOVE : VfsFileTransfer.Mode.COPY;
+        String key =
+            mode == VfsFileTransfer.Mode.MOVE
+                ? "VfsFileExplorer.Operation.Moving"
+                : "VfsFileExplorer.Operation.Copying";
+        copyIntoFolder(
+            target, entries, mode, BaseMessages.getString(PKG, key, entries.get(0).getName()));
+      }
+    };
+  }
+
+  /**
+   * The operation a drop performs. Only an explicit move, the platform's move modifier on the
+   * desktop, moves files; everything else copies. Hop Web reports a drag without a modifier as a
+   * move, so a drop always copies there: Cut and Paste move files.
+   */
+  static int dropOperation(int detail, int operations, boolean web) {
+    boolean canCopy = (operations & DND.DROP_COPY) != 0;
+    if (!web && detail == DND.DROP_MOVE && (operations & DND.DROP_MOVE) != 0) {
+      return DND.DROP_MOVE;
+    }
+    return canCopy ? DND.DROP_COPY : DND.DROP_NONE;
+  }
+
+  /**
+   * A drop into a folder is refused onto one of the dragged items itself, into its own subfolder,
+   * or into the folder the items already sit in. Paths from outside this tab are not known while
+   * dragging, so they are accepted here and checked by the copy itself.
+   */
+  static boolean canDropInto(List<String> uris, String target) {
+    if (uris.isEmpty()) {
+      return true;
+    }
+    boolean allInTarget = true;
+    for (String uri : uris) {
+      if (VfsLocations.sameLocation(uri, target) || VfsLocations.isUnder(uri, target)) {
+        return false;
+      }
+      if (!VfsLocations.sameLocation(VfsLocations.parentOf(uri), target)) {
+        allInTarget = false;
+      }
+    }
+    return !allInTarget;
+  }
+
+  /** For UI tests. */
+  Table fileTable() {
+    return table;
+  }
+
+  /** For UI tests. */
+  Tree folderTree() {
+    return tree;
+  }
+
   void setTabItem(CTabItem tabItem) {
     this.tabItem = tabItem;
   }
@@ -349,60 +585,59 @@ public class VfsFileExplorerLocation extends Composite {
         explorer.beginOperation(
             BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Opening", resolved), resolved);
     Display display = getDisplay();
-    Thread thread =
-        BackgroundThreadFacade.start(
-            () -> {
-              try {
-                if (stopped(operation, token)) {
-                  operation.complete();
-                  refreshPanel(display);
-                  return;
-                }
-                FileObject file = HopVfs.getFileObject(resolved, variables);
-                file.refresh();
-                String selectName = null;
-                FileObject folder = file;
-                if (!file.isFolder()) {
-                  selectName = file.getName().getBaseName();
-                  if (file.getParent() != null) {
-                    folder = file.getParent();
-                    folder.refresh();
-                  }
-                }
-                String folderUri = HopVfs.getFilename(folder);
-                List<VfsFileRow> rows = VfsFileListing.childrenOf(folder);
-                if (stopped(operation, token)) {
-                  operation.complete();
-                  refreshPanel(display);
-                  return;
-                }
-                operation.complete();
-                String selected = selectName;
-                display.asyncExec(
-                    () -> {
-                      if (!canApply(token)) {
-                        explorer.refreshOperations();
-                        return;
-                      }
-                      List<VfsFileRow> published =
-                          VfsFileListing.publish(generation, token, List.of(), rows);
-                      installRoot(folderUri, published);
-                      if (recordHistory) {
-                        recordHistory(folderUri);
-                      }
-                      if (selected != null) {
-                        selectTableName(selected);
-                      }
-                      explorer.refreshOperations();
-                    });
-              } catch (Throwable e) {
-                fail(operation, e);
-                refreshPanel(display);
+    runInBackground(
+        operation,
+        () -> {
+          try {
+            if (stopped(operation, token)) {
+              operation.cancelFromWorker();
+              refreshPanel(display);
+              return;
+            }
+            FileObject file = HopVfs.getFileObject(resolved, variables);
+            file.refresh();
+            String selectName = null;
+            FileObject folder = file;
+            if (!file.isFolder()) {
+              selectName = file.getName().getBaseName();
+              if (file.getParent() != null) {
+                folder = file.getParent();
+                folder.refresh();
               }
-            },
-            "hop-vfs-explorer");
-    operation.attachThread(thread);
-    explorer.refreshOperations();
+            }
+            String folderUri = HopVfs.getFilename(folder);
+            FileObject[] children = VfsFileListing.listChildren(folder);
+            int read = Math.min(children.length, VfsFileListing.PAGE_SIZE);
+            List<VfsFileRow> rows = VfsFileListing.rowsOf(children, 0, read);
+            if (stopped(operation, token)) {
+              operation.cancelFromWorker();
+              refreshPanel(display);
+              return;
+            }
+            operation.complete();
+            String selected = selectName;
+            display.asyncExec(
+                () -> {
+                  if (!canApply(token)) {
+                    explorer.refreshOperations();
+                    return;
+                  }
+                  List<VfsFileRow> published =
+                      VfsFileListing.publish(generation, token, List.of(), rows);
+                  installRoot(folderUri, published, children, read);
+                  if (recordHistory) {
+                    recordHistory(folderUri);
+                  }
+                  if (selected != null) {
+                    selectTableName(selected);
+                  }
+                  explorer.refreshOperations();
+                });
+          } catch (Throwable e) {
+            fail(operation, e);
+            refreshPanel(display);
+          }
+        });
   }
 
   public void refreshFolder() {
@@ -432,30 +667,137 @@ public class VfsFileExplorerLocation extends Composite {
     }
   }
 
+  /** Delete removes the selection of the tree or the file list, whichever has focus. */
+  public void deleteFromShortcut() {
+    Control focus = getDisplay().getFocusControl();
+    if (isUnder(table, focus)) {
+      deleteSelectedFiles();
+    } else if (isUnder(tree, focus)) {
+      deleteSelectedFolders();
+    }
+  }
+
+  public void copyFromShortcut() {
+    Control focus = getDisplay().getFocusControl();
+    if (isUnder(table, focus)) {
+      copySelectedFiles();
+    } else if (isUnder(tree, focus)) {
+      copySelectedFolders();
+    }
+  }
+
+  public void cutFromShortcut() {
+    Control focus = getDisplay().getFocusControl();
+    if (isUnder(table, focus)) {
+      cutSelectedFiles();
+    } else if (isUnder(tree, focus)) {
+      cutSelectedFolders();
+    }
+  }
+
+  public void focusLocation() {
+    if (!locationText.isDisposed()) {
+      locationText.setFocus();
+      locationText.selectAll();
+    }
+  }
+
   public void publishCounts() {
     if (isDisposed()) {
       return;
     }
-    explorer.setListingCounts(this, VfsListingCounts.of(shownRows, selectedTableRows()));
+    int unread = shown == null ? 0 : shown.unread();
+    explorer.setListingCounts(this, VfsListingCounts.of(shownRows, selectedTableRows(), unread));
   }
 
-  public void refreshBookmarks() {
-    if (bookmarksList.isDisposed()) {
+  /**
+   * Fill the places: the project, the named connections, this computer (the server on Hop Web) and
+   * the bookmarks. Connections are read again, so a connection saved since shows up.
+   */
+  public void refreshPlaces() {
+    if (placesTree.isDisposed()) {
       return;
     }
-    List<String> names = new ArrayList<>(explorer.bookmarks().keySet());
-    Collections.sort(names);
-    bookmarksList.setItems(names.toArray(new String[0]));
+    placesTree.removeAll();
+    HopGui hopGui = explorer.getHopGui();
+    addPlaces(
+        "VfsFileExplorer.Places.Project",
+        VfsPlaces.project(variables, BaseMessages.getString(PKG, "VfsFileExplorer.Places.Home")));
+    addPlaces(
+        "VfsFileExplorer.Places.Connections",
+        VfsPlaces.connections(hopGui == null ? null : hopGui.getMetadataProvider(), variables));
+    addPlaces(
+        EnvironmentUtils.getInstance().isWeb()
+            ? "VfsFileExplorer.Places.Server"
+            : "VfsFileExplorer.Places.Local",
+        VfsPlaces.local(BaseMessages.getString(PKG, "VfsFileExplorer.Places.UserHome")));
+    addPlaces("VfsFileExplorer.Places.Bookmarks", VfsPlaces.bookmarks(explorer.bookmarks()));
     updateToolbar();
   }
 
+  /** A section with its places, expanded. An empty section is left out. */
+  private void addPlaces(String titleKey, List<VfsPlaces.Place> places) {
+    if (places.isEmpty()) {
+      return;
+    }
+    TreeItem section = new TreeItem(placesTree, SWT.NONE);
+    section.setText(BaseMessages.getString(PKG, titleKey));
+    for (VfsPlaces.Place place : places) {
+      TreeItem item = new TreeItem(section, SWT.NONE);
+      item.setText(place.getName());
+      item.setImage(placeImage(place));
+      item.setData(place);
+    }
+    section.setExpanded(true);
+  }
+
+  private Image placeImage(VfsPlaces.Place place) {
+    GuiResource resource = GuiResource.getInstance();
+    try {
+      return switch (place.getKind()) {
+        case PROJECT -> resource.getImage("ui/images/home.svg");
+        case BOOKMARK -> resource.getImage("ui/images/bookmark-add.svg");
+        case CONNECTION ->
+            place.getImage() == null
+                ? folderImage
+                : resource.getImage(
+                    place.getImage(),
+                    place.getClassLoader(),
+                    ConstUi.SMALL_ICON_SIZE,
+                    ConstUi.SMALL_ICON_SIZE);
+        case LOCAL -> folderImage;
+      };
+    } catch (Exception e) {
+      return folderImage;
+    }
+  }
+
+  private VfsPlaces.Place selectedPlace() {
+    if (placesTree.isDisposed()) {
+      return null;
+    }
+    TreeItem[] selection = placesTree.getSelection();
+    return selection.length == 1 && selection[0].getData() instanceof VfsPlaces.Place place
+        ? place
+        : null;
+  }
+
+  private void openPlace(Widget item) {
+    if (item instanceof TreeItem treeItem
+        && !treeItem.isDisposed()
+        && treeItem.getData() instanceof VfsPlaces.Place place
+        && StringUtils.isNotEmpty(place.getUri())) {
+      navigateTo(place.getUri(), true);
+    }
+  }
+
   @GuiToolbarElement(
       root = NAVIGATE_TOOLBAR_PARENT_ID,
       id = NAVIGATE_HOME,
       toolTip = "i18n::VfsFileExplorer.Navigate.Home.Tooltip",
       image = "ui/images/home.svg")
   public void navigateHome() {
-    navigateTo(System.getProperty("user.home"), true);
+    navigateTo(VfsFileExplorerViews.defaultLocation(variables), true);
   }
 
   @GuiToolbarElement(
@@ -485,39 +827,36 @@ public class VfsFileExplorerLocation extends Composite {
         explorer.beginOperation(
             BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Opening", uri), uri);
     Display display = getDisplay();
-    Thread thread =
-        BackgroundThreadFacade.start(
-            () -> {
-              try {
-                if (stopped(operation, token)) {
-                  operation.complete();
-                  refreshPanel(display);
-                  return;
-                }
-                FileObject folder = HopVfs.getFileObject(uri, variables);
-                FileObject parent = folder.getParent();
-                if (parent == null) {
-                  operation.complete();
-                  refreshPanel(display);
-                  return;
-                }
-                String parentUri = HopVfs.getFilename(parent);
-                operation.complete();
-                display.asyncExec(
-                    () -> {
-                      if (canApply(token)) {
-                        navigateTo(parentUri, true);
-                      }
-                      explorer.refreshOperations();
-                    });
-              } catch (Throwable e) {
-                fail(operation, e);
-                refreshPanel(display);
-              }
-            },
-            "hop-vfs-explorer");
-    operation.attachThread(thread);
-    explorer.refreshOperations();
+    runInBackground(
+        operation,
+        () -> {
+          try {
+            if (stopped(operation, token)) {
+              operation.cancelFromWorker();
+              refreshPanel(display);
+              return;
+            }
+            FileObject folder = HopVfs.getFileObject(uri, variables);
+            FileObject parent = folder.getParent();
+            if (parent == null) {
+              operation.complete();
+              refreshPanel(display);
+              return;
+            }
+            String parentUri = HopVfs.getFilename(parent);
+            operation.complete();
+            display.asyncExec(
+                () -> {
+                  if (canApply(token)) {
+                    navigateTo(parentUri, true);
+                  }
+                  explorer.refreshOperations();
+                });
+          } catch (Throwable e) {
+            fail(operation, e);
+            refreshPanel(display);
+          }
+        });
   }
 
   @GuiToolbarElement(
@@ -563,6 +902,9 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Tree.CreateFolder.Tooltip",
       image = "ui/images/folder-add.svg")
   public void createFolder() {
+    if (!checkWrite()) {
+      return;
+    }
     FolderNode node = shown != null ? shown : selectedNode();
     if (node == null) {
       return;
@@ -580,7 +922,7 @@ public class VfsFileExplorerLocation extends Composite {
     runChange(
         BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Creating", name),
         node.uri,
-        () -> {
+        operation -> {
           FileObject folder = HopVfs.getFileObject(node.uri, variables);
           folder.resolveFile(name).createFolder();
           return null;
@@ -603,6 +945,9 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Tree.Cut.Tooltip",
       image = "ui/images/cut.svg")
   public void cutSelectedFolders() {
+    if (!checkWrite()) {
+      return;
+    }
     copyEntries(selectedFolderEntries(), VfsFileTransfer.Mode.MOVE);
   }
 
@@ -622,6 +967,9 @@ public class VfsFileExplorerLocation extends Composite {
       image = "ui/images/delete.svg",
       separator = true)
   public void deleteSelectedFolders() {
+    if (!checkWrite()) {
+      return;
+    }
     List<VfsFileTransfer.Entry> folders = selectedFolderEntries();
     if (folders.isEmpty() || !confirmDelete(namesOf(folders))) {
       return;
@@ -630,7 +978,7 @@ public class VfsFileExplorerLocation extends Composite {
     runChange(
         BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Deleting", folders.get(0).getName()),
         folders.get(0).getUri(),
-        () -> deleteEntries(folders, shownUri));
+        operation -> deleteEntries(folders, shownUri));
   }
 
   @GuiToolbarElement(
@@ -639,6 +987,9 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Tree.Rename.Tooltip",
       image = "ui/images/rename.svg")
   public void renameSelectedFolder() {
+    if (!checkWrite()) {
+      return;
+    }
     List<VfsFileTransfer.Entry> folders = selectedFolderEntries();
     if (folders.size() != 1) {
       return;
@@ -652,7 +1003,7 @@ public class VfsFileExplorerLocation extends Composite {
     runChange(
         BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Renaming", folder.getName()),
         uri,
-        () -> renameEntry(uri, newName));
+        operation -> renameEntry(uri, newName));
   }
 
   @GuiToolbarElement(
@@ -701,6 +1052,13 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Details.Download.Tooltip",
       image = "ui/images/download.svg")
   public void downloadSelected() {
+    if (BrowserFileTransferFacade.isSupported()) {
+      downloadToBrowser();
+      return;
+    }
+    if (!checkWrite()) {
+      return;
+    }
     VfsFileRow row = singleFileRow();
     if (row == null) {
       return;
@@ -722,9 +1080,9 @@ public class VfsFileExplorerLocation extends Composite {
     String sourceUri = row.getUri();
     String sourceName = row.getName();
     runChange(
-        BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Downloading", sourceName),
+        BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Copying", sourceName),
         destination,
-        () -> {
+        operation -> {
           FileObject source = HopVfs.getFileObject(sourceUri, variables);
           FileObject chosen = HopVfs.getFileObject(destination, variables);
           FileObject target = chosen;
@@ -735,13 +1093,27 @@ public class VfsFileExplorerLocation extends Composite {
             throw new IllegalStateException(
                 BaseMessages.getString(PKG, "VfsFileExplorer.Transfer.Same", sourceName));
           }
-          if (target.exists()) {
-            if (!askOverwrite()) {
-              return null;
-            }
-            VfsFileTransfer.delete(target);
+          if (!target.exists()) {
+            VfsFileTransfer.copy(source, target, operation::isCancelled, operation.progress());
+            return null;
+          }
+          if (target.isFolder()) {
+            throw new IllegalStateException(
+                transferMessage(
+                    VfsFileTransfer.Refusal.OTHER_KIND, target.getName().getBaseName()));
+          }
+          VfsFileTransfer.Resolution resolution = conflictResolver().resolve(source, target);
+          if (resolution == VfsFileTransfer.Resolution.CANCEL) {
+            throw new CancellationException();
+          }
+          if (resolution == VfsFileTransfer.Resolution.REPLACE) {
+            VfsFileTransfer.replaceFile(
+                source,
+                target,
+                VfsFileTransfer.Mode.COPY,
+                operation::isCancelled,
+                operation.progress());
           }
-          target.copyFrom(source, Selectors.SELECT_ALL);
           return null;
         });
   }
@@ -752,6 +1124,13 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Details.Upload.Tooltip",
       image = "ui/images/upload.svg")
   public void uploadSelected() {
+    if (BrowserFileTransferFacade.isSupported()) {
+      uploadFromBrowser();
+      return;
+    }
+    if (!checkWrite()) {
+      return;
+    }
     List<VfsFileTransfer.Entry> files = selectedUploadEntries();
     if (files.isEmpty()) {
       return;
@@ -772,11 +1151,145 @@ public class VfsFileExplorerLocation extends Composite {
     String description =
         files.size() == 1
             ? BaseMessages.getString(
-                PKG, "VfsFileExplorer.Operation.Uploading", files.get(0).getName())
-            : BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Uploading.Many", files.size());
+                PKG, "VfsFileExplorer.Operation.Copying", files.get(0).getName())
+            : BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Copying.Many", files.size());
     copyIntoFolder(destination, files, VfsFileTransfer.Mode.COPY, description);
   }
 
+  /**
+   * Hop Web: send the selected file to the browser. It is read into a server-side temporary file
+   * first, because a remote file cannot be streamed to the browser directly.
+   */
+  private void downloadToBrowser() {
+    if (!HopSecurityUi.check(Permission.FILE_EXPORT)) {
+      return;
+    }
+    VfsFileRow row = singleFileRow();
+    if (row == null) {
+      return;
+    }
+    String sourceUri = row.getUri();
+    String name = row.getName();
+    Display display = getDisplay();
+    Path local;
+    try {
+      // In the session's temporary folder, which Hop Web removes when the session ends.
+      local = BrowserFileTransferFacade.createTempFile("download-");
+    } catch (Exception e) {
+      failOnUi(e);
+      return;
+    }
+    runChange(
+        BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Downloading", name),
+        sourceUri,
+        false,
+        operation -> {
+          boolean handedOver = false;
+          try {
+            FileObject source = HopVfs.getFileObject(sourceUri, variables);
+            FileObject target = HopVfs.getFileObject(local.toUri().toString());
+            // Stops between chunks, and removes a partly written copy.
+            VfsFileTransfer.copy(source, target, operation::isCancelled, operation.progress());
+            if (operation.isCancelled()) {
+              throw new CancellationException();
+            }
+            display.asyncExec(
+                () -> {
+                  try {
+                    if (!operation.isCancelled() && !isDisposed()) {
+                      BrowserFileTransferFacade.download(name, local);
+                    }
+                  } catch (Exception e) {
+                    failOnUi(e);
+                  } finally {
+                    deleteLocalQuietly(local);
+                  }
+                });
+            handedOver = true;
+          } finally {
+            if (!handedOver) {
+              deleteLocalQuietly(local);
+            }
+          }
+          return null;
+        });
+  }
+
+  /** Hop Web: upload a file from the user's computer into the folder being viewed. */
+  private void uploadFromBrowser() {
+    if (!checkWrite()) {
+      return;
+    }
+    String folderUri = currentFolderUri();
+    if (StringUtils.isBlank(folderUri)) {
+      return;
+    }
+    BrowserFileTransferFacade.upload(
+        BROWSER_UPLOAD_LIMIT,
+        new BrowserFileTransferFacade.IUploadListener() {
+          @Override
+          public void uploaded(String filename, Path uploadedFile) {
+            storeUpload(folderUri, filename, uploadedFile);
+          }
+
+          @Override
+          public void error(Exception exception) {
+            failOnUi(exception);
+          }
+        });
+  }
+
+  private void storeUpload(String folderUri, String filename, Path uploadedFile) {
+    if (StringUtils.isBlank(filename)
+        || filename.contains("/")
+        || filename.contains("\\")
+        || filename.equals("..")) {
+      deleteLocalQuietly(uploadedFile);
+      failOnUi(new IllegalArgumentException(filename));
+      return;
+    }
+    runChange(
+        BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Uploading", filename),
+        folderUri,
+        operation -> {
+          try {
+            FileObject source = HopVfs.getFileObject(uploadedFile.toUri().toString());
+            FileObject target = HopVfs.getFileObject(folderUri, variables).resolveFile(filename);
+            if (!target.exists()) {
+              VfsFileTransfer.copy(source, target, operation::isCancelled, operation.progress());
+              return null;
+            }
+            if (target.isFolder()) {
+              throw new IllegalStateException(
+                  transferMessage(VfsFileTransfer.Refusal.OTHER_KIND, filename));
+            }
+            VfsFileTransfer.Resolution resolution = conflictResolver().resolve(source, target);
+            if (resolution == VfsFileTransfer.Resolution.CANCEL) {
+              throw new CancellationException();
+            }
+            if (resolution == VfsFileTransfer.Resolution.REPLACE) {
+              VfsFileTransfer.replaceFile(
+                  source,
+                  target,
+                  VfsFileTransfer.Mode.COPY,
+                  operation::isCancelled,
+                  operation.progress());
+            }
+            return null;
+          } finally {
+            deleteLocalQuietly(uploadedFile);
+          }
+        });
+  }
+
+  private static void deleteLocalQuietly(Path file) {
+    try {
+      Files.deleteIfExists(file);
+    } catch (Exception ignored) {
+      // A file in the session's temporary folder: Hop Web removes that folder with the session.
+    }
+  }
+
   @GuiToolbarElement(
       root = DETAILS_TOOLBAR_PARENT_ID,
       id = DETAILS_DRILL,
@@ -814,6 +1327,9 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Details.Cut.Tooltip",
       image = "ui/images/cut.svg")
   public void cutSelectedFiles() {
+    if (!checkWrite()) {
+      return;
+    }
     copyEntries(selectedFileEntries(), VfsFileTransfer.Mode.MOVE);
   }
 
@@ -832,6 +1348,9 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Details.Rename.Tooltip",
       image = "ui/images/rename.svg")
   public void renameSelectedFile() {
+    if (!checkWrite()) {
+      return;
+    }
     VfsFileRow row = singleFileRow();
     if (row == null) {
       return;
@@ -844,7 +1363,7 @@ public class VfsFileExplorerLocation extends Composite {
     runChange(
         BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Renaming", row.getName()),
         uri,
-        () -> {
+        operation -> {
           renameEntry(uri, newName);
           return null;
         });
@@ -857,6 +1376,9 @@ public class VfsFileExplorerLocation extends Composite {
       image = "ui/images/delete.svg",
       separator = true)
   public void deleteSelectedFiles() {
+    if (!checkWrite()) {
+      return;
+    }
     List<VfsFileTransfer.Entry> entries = selectedFileEntries();
     if (entries.isEmpty() || !confirmDelete(namesOf(entries))) {
       return;
@@ -865,7 +1387,64 @@ public class VfsFileExplorerLocation extends Composite {
     runChange(
         BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Deleting", entries.get(0).getName()),
         entries.get(0).getUri(),
-        () -> deleteEntries(entries, shownUri));
+        operation -> deleteEntries(entries, shownUri));
+  }
+
+  @GuiToolbarElement(
+      root = DETAILS_TOOLBAR_PARENT_ID,
+      id = DETAILS_MORE,
+      toolTip = "i18n::VfsFileExplorer.Details.More.Tooltip",
+      image = "ui/images/arrow-down.svg",
+      separator = true)
+  public void loadMore() {
+    FolderNode node = shown;
+    if (node == null || node.unread() == 0) {
+      return;
+    }
+    FileObject[] children = node.all;
+    int from = node.read;
+    int to = Math.min(children.length, from + VfsFileListing.PAGE_SIZE);
+    int token = generation.next();
+    VfsExplorerOperation operation =
+        explorer.beginOperation(
+            BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Listing", node.uri), node.uri);
+    Display display = getDisplay();
+    runInBackground(
+        operation,
+        () -> {
+          try {
+            List<VfsFileRow> rows = VfsFileListing.rowsOf(children, from, to);
+            if (stopped(operation, token)) {
+              operation.cancelFromWorker();
+              refreshPanel(display);
+              return;
+            }
+            operation.complete();
+            display.asyncExec(
+                () -> {
+                  // A refresh in the meantime replaced the children: drop this page.
+                  if (!canApply(token) || node.all != children || node.read != from) {
+                    explorer.refreshOperations();
+                    return;
+                  }
+                  List<VfsFileRow> merged = new ArrayList<>(node.children);
+                  merged.addAll(rows);
+                  node.children = merged;
+                  node.read = to;
+                  TreeItem item = findTreeItem(node.uri);
+                  if (item != null && nodeOf(item) == node) {
+                    boolean expanded = item.getExpanded();
+                    replaceChildren(item, node.children);
+                    FolderTreeIcons.setExpanded(item, expanded);
+                  }
+                  fillTable(node);
+                  explorer.refreshOperations();
+                });
+          } catch (Throwable e) {
+            fail(operation, e);
+            refreshPanel(display);
+          }
+        });
   }
 
   @GuiToolbarElement(
@@ -979,11 +1558,21 @@ public class VfsFileExplorerLocation extends Composite {
       toolTip = "i18n::VfsFileExplorer.Bookmark.Remove.Tooltip",
       image = "ui/images/delete.svg")
   public void removeBookmark() {
-    String[] selection = bookmarksList.getSelection();
-    if (selection.length != 1) {
+    VfsPlaces.Place place = selectedPlace();
+    if (place == null || place.getKind() != VfsPlaces.Kind.BOOKMARK) {
       return;
     }
-    explorer.removeBookmark(selection[0]);
+    explorer.removeBookmark(place.getName());
+  }
+
+  @GuiToolbarElement(
+      root = BOOKMARKS_TOOLBAR_PARENT_ID,
+      id = PLACES_REFRESH,
+      toolTip = "i18n::VfsFileExplorer.Places.Refresh.Tooltip",
+      image = "ui/images/refresh.svg",
+      separator = true)
+  public void refreshPlacesFromToolbar() {
+    refreshPlaces();
   }
 
   private void onTreeSelected(TreeItem item) {
@@ -1051,108 +1640,171 @@ public class VfsFileExplorerLocation extends Composite {
     navigateTo(row.getUri(), true);
   }
 
+  /**
+   * List {@code node}. A listing for the table replaces whatever the table was waiting for. A
+   * listing that only expands the tree has its own token per folder, so it does not drop the
+   * table's listing or another folder's.
+   */
   private void startList(TreeItem item, FolderNode node, boolean showTable) {
-    int token = generation.next();
+    VfsListingGeneration listing = showTable ? generation : node.listing;
+    int token = listing.next();
     String uri = node.uri;
     VfsExplorerOperation operation =
         explorer.beginOperation(
             BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Listing", uri), uri);
     Display display = getDisplay();
-    Thread thread =
-        BackgroundThreadFacade.start(
-            () -> {
-              try {
-                if (stopped(operation, token)) {
-                  operation.complete();
-                  refreshPanel(display);
-                  return;
-                }
-                FileObject folder = HopVfs.getFileObject(uri, variables);
-                folder.refresh();
-                List<VfsFileRow> rows = VfsFileListing.childrenOf(folder);
-                if (stopped(operation, token)) {
-                  operation.complete();
-                  refreshPanel(display);
-                  return;
-                }
-                operation.complete();
-                display.asyncExec(
-                    () -> {
-                      if (!canApply(token) || item.isDisposed()) {
-                        explorer.refreshOperations();
-                        return;
-                      }
-                      node.children =
-                          VfsFileListing.publish(generation, token, node.children, rows);
-                      node.loaded = true;
-                      replaceChildren(item, node.children);
-                      if (showTable || isSelected(item)) {
-                        setLocationText(uri);
-                        fillTable(node);
-                      }
-                      explorer.refreshOperations();
-                    });
-              } catch (Throwable e) {
-                fail(operation, e);
-                refreshPanel(display);
-              }
-            },
-            "hop-vfs-explorer");
+    runInBackground(
+        operation,
+        () -> {
+          try {
+            if (operation.isCancelled() || !listing.isCurrent(token)) {
+              dropListing(operation, display, item, node);
+              return;
+            }
+            FileObject folder = HopVfs.getFileObject(uri, variables);
+            folder.refresh();
+            FileObject[] children = VfsFileListing.listChildren(folder);
+            int read = Math.min(children.length, VfsFileListing.PAGE_SIZE);
+            List<VfsFileRow> rows = VfsFileListing.rowsOf(children, 0, read);
+            if (operation.isCancelled() || !listing.isCurrent(token)) {
+              dropListing(operation, display, item, node);
+              return;
+            }
+            operation.complete();
+            display.asyncExec(
+                () -> {
+                  if (isDisposed() || !listing.isCurrent(token) || item.isDisposed()) {
+                    explorer.refreshOperations();
+                    return;
+                  }
+                  node.children = VfsFileListing.publish(listing, token, node.children, rows);
+                  node.all = children;
+                  node.read = read;
+                  node.loaded = true;
+                  replaceChildren(item, node.children);
+                  if (showTable || isSelected(item)) {
+                    setLocationText(uri);
+                    fillTable(node);
+                  }
+                  explorer.refreshOperations();
+                });
+          } catch (Throwable e) {
+            fail(operation, e);
+            collapseUnloaded(display, item, node);
+          }
+        });
+  }
+
+  /**
+   * Run {@code work} on its own thread. A hung server keeps its thread, so past {@link
+   * VfsFileExplorer#MAX_RUNNING_OPERATIONS} unfinished operations a new one fails instead of
+   * starting yet another thread.
+   */
+  private void runInBackground(VfsExplorerOperation operation, Runnable work) {
+    if (explorer.runningOperations() > VfsFileExplorer.MAX_RUNNING_OPERATIONS) {
+      operation.fail(
+          BaseMessages.getString(
+              PKG, "VfsFileExplorer.Error.TooManyRunning", VfsFileExplorer.MAX_RUNNING_OPERATIONS),
+          null);
+      explorer.refreshOperations();
+      return;
+    }
+    Thread thread = BackgroundThreadFacade.start(work, "hop-vfs-explorer");
     operation.attachThread(thread);
     explorer.refreshOperations();
   }
 
+  /** A stopped or replaced listing shows as cancelled, not as done. */
+  private void dropListing(
+      VfsExplorerOperation operation, Display display, TreeItem item, FolderNode node) {
+    operation.cancelFromWorker();
+    collapseUnloaded(display, item, node);
+  }
+
+  /**
+   * A folder whose listing did not finish keeps only its placeholder row. Collapse it, so expanding
+   * it again lists it again.
+   */
+  private void collapseUnloaded(Display display, TreeItem item, FolderNode node) {
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.asyncExec(
+        () -> {
+          if (!isDisposed()) {
+            explorer.refreshOperations();
+          }
+          if (item != null && !item.isDisposed() && !node.loaded) {
+            FolderTreeIcons.setExpanded(item, false);
+          }
+        });
+  }
+
   private void runChange(String description, String location, Change change) {
+    runChange(description, location, true, change);
+  }
+
+  /**
+   * Run a file change in the background. With {@code reload}, the folder being viewed is listed
+   * again afterwards, or the folder the change returns is opened.
+   */
+  private void runChange(String description, String location, boolean reload, Change change) {
     TreeItem item = findTreeItem(currentFolderUri());
     VfsExplorerOperation operation = explorer.beginOperation(description, location);
     Display display = getDisplay();
-    Thread thread =
-        BackgroundThreadFacade.start(
-            () -> {
-              String show = null;
-              try {
-                if (operation.isCancelled()) {
-                  operation.complete();
-                  refreshPanel(display);
+    runInBackground(
+        operation,
+        () -> {
+          String show = null;
+          try {
+            if (operation.isCancelled()) {
+              operation.complete();
+              refreshPanel(display);
+              return;
+            }
+            show = change.run(operation);
+            operation.complete();
+          } catch (CancellationException e) {
+            operation.cancelFromWorker();
+          } catch (Throwable e) {
+            fail(operation, e);
+          }
+          String folder = show;
+          display.asyncExec(
+              () -> {
+                explorer.refreshOperations();
+                if (isDisposed()) {
                   return;
                 }
-                show = change.run();
-                operation.complete();
-              } catch (Throwable e) {
-                fail(operation, e);
-              }
-              String folder = show;
-              display.asyncExec(
-                  () -> {
-                    explorer.refreshOperations();
-                    if (isDisposed()) {
-                      return;
-                    }
-                    if (StringUtils.isNotEmpty(folder)) {
-                      navigateTo(folder, true);
-                      return;
-                    }
-                    TreeItem refreshItem = item;
-                    if (refreshItem == null || refreshItem.isDisposed()) {
-                      refreshItem = findTreeItem(currentFolderUri());
-                    }
-                    FolderNode node = nodeOf(refreshItem);
-                    if (refreshItem != null && node != null) {
-                      startList(refreshItem, node, true);
-                    }
-                    updateToolbar();
-                  });
-            },
-            "hop-vfs-explorer");
-    operation.attachThread(thread);
-    explorer.refreshOperations();
+                if (!reload) {
+                  updateToolbar();
+                  return;
+                }
+                if (StringUtils.isNotEmpty(folder)) {
+                  navigateTo(folder, true);
+                  return;
+                }
+                TreeItem refreshItem = item;
+                if (refreshItem == null || refreshItem.isDisposed()) {
+                  refreshItem = findTreeItem(currentFolderUri());
+                }
+                FolderNode node = nodeOf(refreshItem);
+                if (refreshItem != null && node != null) {
+                  startList(refreshItem, node, true);
+                }
+                updateToolbar();
+              });
+        });
   }
 
-  private void installRoot(String folderUri, List<VfsFileRow> rows) {
+  private void installRoot(
+      String folderUri, List<VfsFileRow> rows, FileObject[] children, int read) {
     tree.removeAll();
     FolderNode node = new FolderNode(folderUri, titleOf(folderUri));
     node.loaded = true;
     node.children = rows;
+    node.all = children;
+    node.read = read;
     TreeItem item = new TreeItem(tree, SWT.NONE);
     item.setText(node.name);
     item.setImage(folderImage);
@@ -1207,17 +1859,31 @@ public class VfsFileExplorerLocation extends Composite {
             state.getSortColumn(),
             state.isAscending());
     shownRows = rows;
-    table.removeAll();
+    table.deselectAll();
+    table.clearAll();
+    table.setItemCount(rows.size());
+    updateToolbar();
+  }
+
+  /** Virtual table: an item is filled when it is first shown. */
+  private void fillItem(TableItem item) {
+    int index = table.indexOf(item);
+    if (index < 0 || index >= shownRows.size()) {
+      return;
+    }
+    VfsFileRow row = shownRows.get(index);
     List<VfsFileColumn> columns = shownColumns();
-    for (VfsFileRow row : rows) {
-      TableItem item = new TableItem(table, SWT.NONE);
-      item.setData(row);
-      item.setImage(imageFor(row));
-      for (int i = 0; i < columns.size(); i++) {
-        item.setText(i, textFor(row, columns.get(i)));
-      }
+    item.setData(row);
+    item.setImage(imageFor(row));
+    for (int i = 0; i < columns.size(); i++) {
+      item.setText(i, textFor(row, columns.get(i)));
+    }
+  }
+
+  private void refillTableIfAlive() {
+    if (!isDisposed() && !table.isDisposed()) {
+      refillTable();
     }
-    updateToolbar();
   }
 
   private void rebuildColumns() {
@@ -1276,9 +1942,9 @@ public class VfsFileExplorerLocation extends Composite {
   }
 
   private void selectTableName(String name) {
-    for (TableItem item : table.getItems()) {
-      if (item.getData() instanceof VfsFileRow row && name.equals(row.getName())) {
-        table.setSelection(item);
+    for (int i = 0; i < shownRows.size(); i++) {
+      if (name.equals(shownRows.get(i).getName())) {
+        table.setSelection(i);
         table.showSelection();
         updateToolbar();
         return;
@@ -1317,15 +1983,18 @@ public class VfsFileExplorerLocation extends Composite {
     }
     navigateToolbar.enableToolbarItem(NAVIGATE_BACK, historyIndex > 0);
     navigateToolbar.enableToolbarItem(NAVIGATE_FORWARD, historyIndex + 1 < history.size());
+    boolean canWrite = canWrite();
     List<VfsFileTransfer.Entry> folders = selectedFolderEntries();
     boolean folderSelected = !folders.isEmpty();
     boolean oneFolder = folders.size() == 1;
-    boolean canPaste = !VfsFileClipboard.isEmpty() && StringUtils.isNotEmpty(currentFolderUri());
+    boolean canPaste =
+        canWrite && !clipboard().isEmpty() && StringUtils.isNotEmpty(currentFolderUri());
+    treeToolbar.enableToolbarItem(TREE_CREATE, canWrite);
     treeToolbar.enableToolbarItem(TREE_COPY, folderSelected);
-    treeToolbar.enableToolbarItem(TREE_CUT, folderSelected);
+    treeToolbar.enableToolbarItem(TREE_CUT, canWrite && folderSelected);
     treeToolbar.enableToolbarItem(TREE_PASTE, canPaste);
-    treeToolbar.enableToolbarItem(TREE_DELETE, folderSelected);
-    treeToolbar.enableToolbarItem(TREE_RENAME, oneFolder);
+    treeToolbar.enableToolbarItem(TREE_DELETE, canWrite && folderSelected);
+    treeToolbar.enableToolbarItem(TREE_RENAME, canWrite && oneFolder);
     syncHiddenImage(treeToolbar, TREE_HIDDEN, explorer.getViewState().isShowHiddenFolders());
 
     VfsFileRow row = singleTableRow();
@@ -1334,17 +2003,20 @@ public class VfsFileExplorerLocation extends Composite {
     boolean tableSelected = !tableRows.isEmpty();
     detailsToolbar.enableToolbarItem(DETAILS_OPEN, row != null && canOpen(row));
     detailsToolbar.enableToolbarItem(DETAILS_OPEN_TEXT, file != null);
-    detailsToolbar.enableToolbarItem(DETAILS_DOWNLOAD, file != null);
-    detailsToolbar.enableToolbarItem(DETAILS_UPLOAD, hasSelectedFiles());
+    detailsToolbar.enableToolbarItem(DETAILS_DOWNLOAD, file != null && canDownload());
+    detailsToolbar.enableToolbarItem(DETAILS_UPLOAD, canUpload());
     detailsToolbar.enableToolbarItem(
         DETAILS_DRILL, file != null && HopVfsFileDialog.getArchiveScheme(file.getName()) != null);
     detailsToolbar.enableToolbarItem(DETAILS_COPY, tableSelected);
-    detailsToolbar.enableToolbarItem(DETAILS_CUT, tableSelected);
+    detailsToolbar.enableToolbarItem(DETAILS_CUT, canWrite && tableSelected);
     detailsToolbar.enableToolbarItem(DETAILS_PASTE, canPaste);
-    detailsToolbar.enableToolbarItem(DETAILS_RENAME, file != null);
-    detailsToolbar.enableToolbarItem(DETAILS_DELETE, tableSelected);
+    detailsToolbar.enableToolbarItem(DETAILS_RENAME, canWrite && file != null);
+    detailsToolbar.enableToolbarItem(DETAILS_DELETE, canWrite && tableSelected);
+    detailsToolbar.enableToolbarItem(DETAILS_MORE, shown != null && shown.unread() > 0);
     syncHiddenImage(detailsToolbar, DETAILS_HIDDEN, explorer.getViewState().isShowHiddenFiles());
-    bookmarksToolbar.enableToolbarItem(BOOKMARK_REMOVE, bookmarksList.getSelectionIndex() >= 0);
+    VfsPlaces.Place place = selectedPlace();
+    bookmarksToolbar.enableToolbarItem(
+        BOOKMARK_REMOVE, place != null && place.getKind() == VfsPlaces.Kind.BOOKMARK);
     publishCounts();
   }
 
@@ -1388,17 +2060,6 @@ public class VfsFileExplorerLocation extends Composite {
     return fileImage;
   }
 
-  private void openSelectedBookmark() {
-    String[] selection = bookmarksList.getSelection();
-    if (selection.length != 1) {
-      return;
-    }
-    String path = explorer.bookmarks().get(selection[0]);
-    if (StringUtils.isNotEmpty(path)) {
-      navigateTo(path, true);
-    }
-  }
-
   private String currentFolderUri() {
     if (shown != null) {
       return shown.uri;
@@ -1410,12 +2071,16 @@ public class VfsFileExplorerLocation extends Composite {
     if (entries.isEmpty()) {
       return;
     }
-    VfsFileClipboard.set(mode, entries);
+    clipboard().set(mode, entries);
     updateToolbar();
   }
 
-  private void pasteClipboard() {
-    List<VfsFileTransfer.Entry> entries = VfsFileClipboard.entries();
+  void pasteClipboard() {
+    if (!checkWrite()) {
+      return;
+    }
+    VfsFileClipboard clipboard = clipboard();
+    List<VfsFileTransfer.Entry> entries = clipboard.entries();
     if (entries.isEmpty()) {
       return;
     }
@@ -1423,7 +2088,7 @@ public class VfsFileExplorerLocation extends Composite {
     if (StringUtils.isBlank(destinationUri)) {
       return;
     }
-    VfsFileTransfer.Mode mode = VfsFileClipboard.mode();
+    VfsFileTransfer.Mode mode = clipboard.mode();
     String operationKey =
         mode == VfsFileTransfer.Mode.MOVE
             ? "VfsFileExplorer.Operation.Moving"
@@ -1432,7 +2097,8 @@ public class VfsFileExplorerLocation extends Composite {
         destinationUri,
         entries,
         mode,
-        BaseMessages.getString(PKG, operationKey, entries.get(0).getName()));
+        BaseMessages.getString(PKG, operationKey, entries.get(0).getName()),
+        clipboard);
   }
 
   private void copyIntoFolder(
@@ -1440,46 +2106,52 @@ public class VfsFileExplorerLocation extends Composite {
       List<VfsFileTransfer.Entry> entries,
       VfsFileTransfer.Mode mode,
       String description) {
+    copyIntoFolder(destinationUri, entries, mode, description, null);
+  }
+
+  /**
+   * Copy or move {@code entries} into a folder in the background. A move pasted from {@code
+   * clipboard} empties it: the files are no longer where it points.
+   */
+  private void copyIntoFolder(
+      String destinationUri,
+      List<VfsFileTransfer.Entry> entries,
+      VfsFileTransfer.Mode mode,
+      String description,
+      VfsFileClipboard clipboard) {
     if (StringUtils.isBlank(destinationUri) || entries.isEmpty()) {
       return;
     }
     runChange(
         description,
         destinationUri,
-        () -> {
+        operation -> {
           FileObject destination = HopVfs.getFileObject(destinationUri, variables);
           if (!destination.isFolder()) {
             throw new IllegalStateException(destinationUri);
           }
-          List<String> errors = new ArrayList<>();
-          boolean overwrite = false;
-          boolean asked = false;
-          int done = 0;
-          for (VfsFileTransfer.Entry entry : entries) {
-            if (Thread.currentThread().isInterrupted()) {
-              break;
-            }
-            FileObject source = HopVfs.getFileObject(entry.getUri(), variables);
-            VfsFileTransfer.Refusal refusal = VfsFileTransfer.refusal(source, destination);
-            if (refusal == VfsFileTransfer.Refusal.EXISTS) {
-              if (!asked) {
-                overwrite = askOverwrite();
-                asked = true;
-              }
-              if (!overwrite) {
-                continue;
-              }
-            } else if (refusal != VfsFileTransfer.Refusal.NONE) {
-              errors.add(transferMessage(refusal, entry.getName()));
-              continue;
-            }
-            VfsFileTransfer.transfer(source, destination, mode, overwrite);
-            done++;
+          VfsFileTransfer.BatchResult result =
+              VfsFileTransfer.transferAll(
+                  entries,
+                  destination,
+                  mode,
+                  conflictResolver(),
+                  operation::isCancelled,
+                  operation.progress(),
+                  uri -> HopVfs.getFileObject(uri, variables));
+          if (clipboard != null && mode == VfsFileTransfer.Mode.MOVE) {
+            // Moved files are gone from where the cut was. Skipped, refused and failed ones stay
+            // on the clipboard, so the cut can be finished.
+            clipboard.retain(entries, result.getRemaining());
           }
-          if (mode == VfsFileTransfer.Mode.MOVE && done > 0) {
-            VfsFileClipboard.clear();
+          if (result.isCancelled()) {
+            throw new CancellationException();
           }
-          if (!errors.isEmpty()) {
+          if (!result.getFailures().isEmpty()) {
+            List<String> errors = new ArrayList<>();
+            for (VfsFileTransfer.Failure failure : result.getFailures()) {
+              errors.add(failureMessage(failure));
+            }
             throw new IllegalStateException(String.join("\n", errors));
           }
           return null;
@@ -1496,7 +2168,7 @@ public class VfsFileExplorerLocation extends Composite {
       }
       FileObject file = HopVfs.getFileObject(entry.getUri(), variables);
       boolean containsShown =
-          VfsFileExplorer.sameLocation(shownUri, entry.getUri())
+          VfsLocations.sameLocation(shownUri, entry.getUri())
               || VfsLocations.isUnder(entry.getUri(), shownUri);
       if (containsShown) {
         FileObject parent = file.getParent();
@@ -1575,23 +2247,65 @@ public class VfsFileExplorerLocation extends Composite {
     return box.open() == SWT.YES;
   }
 
-  private boolean askOverwrite() {
-    boolean[] yes = {false};
+  /**
+   * Asks about each file that already exists, on the UI thread. Replace all and Skip all answer the
+   * remaining files of the same paste.
+   */
+  private VfsFileTransfer.ConflictResolver conflictResolver() {
+    VfsFileTransfer.Resolution[] always = {null};
+    return (source, target) -> {
+      if (always[0] != null) {
+        return always[0];
+      }
+      int choice = askConflict(target);
+      return switch (choice) {
+        case 0 -> VfsFileTransfer.Resolution.REPLACE;
+        case 1 -> always[0] = VfsFileTransfer.Resolution.REPLACE;
+        case 2 -> VfsFileTransfer.Resolution.SKIP;
+        case 3 -> always[0] = VfsFileTransfer.Resolution.SKIP;
+        default -> VfsFileTransfer.Resolution.CANCEL;
+      };
+    };
+  }
+
+  /** Button index of the answer, or -1 when the dialog was closed. */
+  private int askConflict(FileObject target) {
+    int[] choice = {-1};
     Display display = getDisplay();
     if (display == null || display.isDisposed()) {
-      return false;
+      return -1;
     }
+    String name = target.getName().getBaseName();
+    String folder = parentText(target);
     display.syncExec(
         () -> {
           if (isDisposed()) {
             return;
           }
-          MessageBox box = new MessageBox(getShell(), SWT.YES | SWT.NO | SWT.ICON_QUESTION);
-          box.setText(BaseMessages.getString(PKG, "VfsFileExplorer.Paste.Overwrite.Title"));
-          box.setMessage(BaseMessages.getString(PKG, "VfsFileExplorer.Paste.Overwrite.Message"));
-          yes[0] = box.open() == SWT.YES;
+          DialogBoxWithButtons box =
+              new DialogBoxWithButtons(
+                  getShell(),
+                  BaseMessages.getString(PKG, "VfsFileExplorer.Conflict.Title"),
+                  BaseMessages.getString(PKG, "VfsFileExplorer.Conflict.Message", name, folder),
+                  new String[] {
+                    BaseMessages.getString(PKG, "VfsFileExplorer.Conflict.Replace"),
+                    BaseMessages.getString(PKG, "VfsFileExplorer.Conflict.ReplaceAll"),
+                    BaseMessages.getString(PKG, "VfsFileExplorer.Conflict.Skip"),
+                    BaseMessages.getString(PKG, "VfsFileExplorer.Conflict.SkipAll"),
+                    BaseMessages.getString(PKG, "VfsFileExplorer.Conflict.Cancel")
+                  });
+          choice[0] = box.open();
         });
-    return yes[0];
+    return choice[0];
+  }
+
+  private static String parentText(FileObject file) {
+    try {
+      FileObject parent = file.getParent();
+      return parent == null ? "" : HopVfs.getFilename(parent);
+    } catch (Exception e) {
+      return "";
+    }
   }
 
   private void showFolderMenu() {
@@ -1600,28 +2314,36 @@ public class VfsFileExplorerLocation extends Composite {
     }
     List<VfsFileTransfer.Entry> folders = selectedFolderEntries();
     boolean any = !folders.isEmpty();
+    boolean canWrite = canWrite();
     Menu menu = new Menu(tree);
     menuItem(
         menu, "VfsFileExplorer.Menu.Copy", "ui/images/copy.svg", any, this::copySelectedFolders);
-    menuItem(menu, "VfsFileExplorer.Menu.Cut", "ui/images/cut.svg", any, this::cutSelectedFolders);
+    menuItem(
+        menu,
+        "VfsFileExplorer.Menu.Cut",
+        "ui/images/cut.svg",
+        canWrite && any,
+        this::cutSelectedFolders);
     menuItem(
         menu,
         "VfsFileExplorer.Menu.Paste",
         "ui/images/paste.svg",
-        !VfsFileClipboard.isEmpty(),
+        canWrite && !clipboard().isEmpty(),
         this::pasteIntoFolder);
     menuItem(
         menu,
         "VfsFileExplorer.Menu.Rename",
         "ui/images/rename.svg",
-        folders.size() == 1,
+        canWrite && folders.size() == 1,
         this::renameSelectedFolder);
     new MenuItem(menu, SWT.SEPARATOR);
+    addCopyPathItems(menu, urisOf(folders));
+    new MenuItem(menu, SWT.SEPARATOR);
     menuItem(
         menu,
         "VfsFileExplorer.Menu.Delete",
         "ui/images/delete.svg",
-        any,
+        canWrite && any,
         this::deleteSelectedFolders);
     replaceMenu(tree, menu);
   }
@@ -1633,6 +2355,7 @@ public class VfsFileExplorerLocation extends Composite {
     VfsFileRow row = singleTableRow();
     VfsFileRow file = singleFileRow();
     boolean any = !selectedTableRows().isEmpty();
+    boolean canWrite = canWrite();
     Menu menu = new Menu(table);
     menuItem(
         menu,
@@ -1646,17 +2369,18 @@ public class VfsFileExplorerLocation extends Composite {
         "ui/images/file.svg",
         file != null,
         this::openSelectedAsText);
+    boolean web = BrowserFileTransferFacade.isSupported();
     menuItem(
         menu,
-        "VfsFileExplorer.Menu.Download",
+        web ? "VfsFileExplorer.Menu.Download.Web" : "VfsFileExplorer.Menu.Download",
         "ui/images/download.svg",
-        file != null,
+        file != null && canDownload(),
         this::downloadSelected);
     menuItem(
         menu,
-        "VfsFileExplorer.Menu.Upload",
+        web ? "VfsFileExplorer.Menu.Upload.Web" : "VfsFileExplorer.Menu.Upload",
         "ui/images/upload.svg",
-        hasSelectedFiles(),
+        canUpload(),
         this::uploadSelected);
     menuItem(
         menu,
@@ -1666,29 +2390,85 @@ public class VfsFileExplorerLocation extends Composite {
         this::drillInto);
     new MenuItem(menu, SWT.SEPARATOR);
     menuItem(menu, "VfsFileExplorer.Menu.Copy", "ui/images/copy.svg", any, this::copySelectedFiles);
-    menuItem(menu, "VfsFileExplorer.Menu.Cut", "ui/images/cut.svg", any, this::cutSelectedFiles);
+    menuItem(
+        menu,
+        "VfsFileExplorer.Menu.Cut",
+        "ui/images/cut.svg",
+        canWrite && any,
+        this::cutSelectedFiles);
     menuItem(
         menu,
         "VfsFileExplorer.Menu.Paste",
         "ui/images/paste.svg",
-        !VfsFileClipboard.isEmpty(),
+        canWrite && !clipboard().isEmpty(),
         this::pasteIntoFileList);
     menuItem(
         menu,
         "VfsFileExplorer.Menu.Rename",
         "ui/images/rename.svg",
-        file != null,
+        canWrite && file != null,
         this::renameSelectedFile);
     new MenuItem(menu, SWT.SEPARATOR);
+    addCopyPathItems(menu, urisOf(selectedFileEntries()));
+    new MenuItem(menu, SWT.SEPARATOR);
     menuItem(
         menu,
         "VfsFileExplorer.Menu.Delete",
         "ui/images/delete.svg",
-        any,
+        canWrite && any,
         this::deleteSelectedFiles);
     replaceMenu(table, menu);
   }
 
+  /**
+   * Copy the paths to the system clipboard, as they are or relative to the project home. One path
+   * per line.
+   */
+  private void addCopyPathItems(Menu menu, List<String> uris) {
+    menuItem(
+        menu,
+        "VfsFileExplorer.Menu.CopyPath",
+        "ui/images/copy.svg",
+        !uris.isEmpty(),
+        () -> GuiResource.getInstance().toClipboard(String.join(Const.CR, uris)));
+    List<String> relative = relativeToProject(uris);
+    menuItem(
+        menu,
+        "VfsFileExplorer.Menu.CopyProjectPath",
+        "ui/images/copy.svg",
+        relative != null,
+        () -> GuiResource.getInstance().toClipboard(String.join(Const.CR, relative)));
+  }
+
+  /** The paths relative to {@code PROJECT_HOME}, or null when one of them is outside it. */
+  private List<String> relativeToProject(List<String> uris) {
+    if (uris.isEmpty() || variables == null) {
+      return null;
+    }
+    String projectHome = variables.getVariable(PROJECT_HOME);
+    if (StringUtils.isBlank(projectHome)) {
+      return null;
+    }
+    projectHome = variables.resolve(projectHome);
+    List<String> relative = new ArrayList<>();
+    for (String uri : uris) {
+      String path = VfsLocations.relativeTo(uri, PROJECT_HOME, projectHome);
+      if (path == null) {
+        return null;
+      }
+      relative.add(path);
+    }
+    return relative;
+  }
+
+  private static List<String> urisOf(List<VfsFileTransfer.Entry> entries) {
+    List<String> uris = new ArrayList<>();
+    for (VfsFileTransfer.Entry entry : entries) {
+      uris.add(entry.getUri());
+    }
+    return uris;
+  }
+
   private void menuItem(Menu menu, String key, String image, boolean enabled, Runnable action) {
     MenuItem item = new MenuItem(menu, SWT.PUSH);
     item.setText(BaseMessages.getString(PKG, key));
@@ -1807,12 +2587,24 @@ public class VfsFileExplorerLocation extends Composite {
     return names;
   }
 
+  private static String failureMessage(VfsFileTransfer.Failure failure) {
+    if (failure.getRefusal() != null) {
+      return transferMessage(failure.getRefusal(), failure.getName());
+    }
+    return BaseMessages.getString(
+        PKG,
+        "VfsFileExplorer.Transfer.Failed",
+        failure.getName(),
+        VfsExplorerOperation.describe(failure.getError()));
+  }
+
   private static String transferMessage(VfsFileTransfer.Refusal refusal, String name) {
     String key =
         switch (refusal) {
           case SAME -> "VfsFileExplorer.Transfer.Same";
           case INTO_ITSELF -> "VfsFileExplorer.Transfer.IntoItself";
           case EXISTS -> "VfsFileExplorer.Transfer.Exists";
+          case OTHER_KIND -> "VfsFileExplorer.Transfer.OtherKind";
           default -> "VfsFileExplorer.Error.Title";
         };
     return BaseMessages.getString(PKG, key, name);
@@ -1831,7 +2623,7 @@ public class VfsFileExplorerLocation extends Composite {
         continue;
       }
       FolderNode node = nodeOf(item);
-      if (node != null && VfsFileExplorer.sameLocation(uri, node.uri)) {
+      if (node != null && VfsLocations.sameLocation(uri, node.uri)) {
         return item;
       }
       TreeItem nested = findTreeItem(item.getItems(), uri);
@@ -1872,14 +2664,18 @@ public class VfsFileExplorerLocation extends Composite {
 
   private boolean isSelected(TreeItem item) {
     FolderNode node = nodeOf(item);
-    return node != null && shown != null && VfsFileExplorer.sameLocation(node.uri, shown.uri);
+    return node != null && shown != null && VfsLocations.sameLocation(node.uri, shown.uri);
   }
 
   private List<VfsFileRow> selectedTableRows() {
     List<VfsFileRow> rows = new ArrayList<>();
-    for (TableItem item : table.getSelection()) {
-      if (item.getData() instanceof VfsFileRow row) {
-        rows.add(row);
+    if (table.isDisposed()) {
+      return rows;
+    }
+    // By index: a virtual table has no data on items that were never shown, e.g. after Ctrl+A.
+    for (int index : table.getSelectionIndices()) {
+      if (index >= 0 && index < shownRows.size()) {
+        rows.add(shownRows.get(index));
       }
     }
     return rows;
@@ -1895,6 +2691,37 @@ public class VfsFileExplorerLocation extends Composite {
     return row != null && !row.isFolder() ? row : null;
   }
 
+  private VfsFileClipboard clipboard() {
+    return VfsFileClipboard.of(getDisplay());
+  }
+
+  /** Hop Web users without explorer write access may browse, but not change files. */
+  private static boolean canWrite() {
+    return HopSecurity.allows(Permission.EXPLORER_WRITE);
+  }
+
+  /** A browser download is an export on Hop Web. On the desktop it writes a copy. */
+  private static boolean canDownload() {
+    return BrowserFileTransferFacade.isSupported()
+        ? HopSecurity.allows(Permission.FILE_EXPORT)
+        : canWrite();
+  }
+
+  /** A browser upload goes into the folder being viewed. On the desktop it copies the selection. */
+  private boolean canUpload() {
+    if (!canWrite()) {
+      return false;
+    }
+    return BrowserFileTransferFacade.isSupported()
+        ? StringUtils.isNotEmpty(currentFolderUri())
+        : hasSelectedFiles();
+  }
+
+  /** Shortcuts reach the actions even when their buttons are disabled. */
+  private static boolean checkWrite() {
+    return HopSecurityUi.check(Permission.EXPLORER_WRITE);
+  }
+
   private boolean stopped(VfsExplorerOperation operation, int token) {
     return operation.isCancelled() || !generation.isCurrent(token);
   }
@@ -1976,19 +2803,32 @@ public class VfsFileExplorerLocation extends Composite {
 
   @FunctionalInterface
   private interface Change {
-    /** Folder to open when the current one was renamed or removed, or null to reload it. */
-    String run() throws Exception;
+    /**
+     * Folder to open when the current one was renamed or removed, or null to reload it. Long
+     * changes check {@code operation} to stop early.
+     */
+    String run(VfsExplorerOperation operation) throws Exception;
   }
 
   private static final class FolderNode {
     private final String uri;
     private final String name;
+    private final VfsListingGeneration listing = new VfsListingGeneration();
     private boolean loaded;
     private List<VfsFileRow> children = List.of();
 
+    /** Every child of the folder. The details of the first {@link #read} are in the rows. */
+    private FileObject[] all = new FileObject[0];
+
+    private int read;
+
     private FolderNode(String uri, String name) {
       this.uri = uri;
       this.name = StringUtils.isBlank(name) ? uri : name;
     }
+
+    private int unread() {
+      return Math.max(0, all.length - read);
+    }
   }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerViews.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerViews.java
index 50640161c4..c936b07587 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerViews.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerViews.java
@@ -20,6 +20,8 @@ package org.apache.hop.ui.hopgui.vfs.explorer;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.IVfsBrowseLocation;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.FormDataBuilder;
@@ -27,6 +29,7 @@ import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.metadata.MetadataEditor;
+import org.apache.hop.ui.core.security.HopSecurityUi;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
@@ -91,6 +94,9 @@ public class VfsFileExplorerViews {
         return;
       }
       if (editor.hasChanged()) {
+        if (!confirmSave(editor)) {
+          return;
+        }
         editor.save();
       }
       String root = location.getBrowseRoot(editor.getHopGui().getVariables());
@@ -111,13 +117,21 @@ public class VfsFileExplorerViews {
     }
   }
 
+  /** VFS resolves the saved connection, so unsaved edits are saved first, after asking. */
+  private static boolean confirmSave(MetadataEditor<?> editor) {
+    MessageBox box = new MessageBox(editor.getShell(), SWT.YES | SWT.NO | SWT.ICON_QUESTION);
+    box.setText(BaseMessages.getString(PKG, "VfsFileExplorer.Explore.Save.Title"));
+    box.setMessage(BaseMessages.getString(PKG, "VfsFileExplorer.Explore.Save.Message"));
+    return box.open() == SWT.YES;
+  }
+
   @SuppressWarnings({"rawtypes", "unchecked"})
   private static void copyWidgets(MetadataEditor editor) {
     editor.getWidgetsContent(editor.getMetadata());
   }
 
   public static void openDock(HopGui hopGui, String location) {
-    if (hopGui == null) {
+    if (hopGui == null || !HopSecurityUi.check(Permission.FILE_VIEW)) {
       return;
     }
     HopGuiBottomDock dock = hopGui.getTerminalPanel();
@@ -132,11 +146,10 @@ public class VfsFileExplorerViews {
             GuiResource.getInstance().getImageFolder(),
             true,
             container -> createDockedExplorer(container, hopGui, location));
-    if (control instanceof VfsFileExplorer explorer && !explorer.isDisposed()) {
-      explorer.activate();
-      if (StringUtils.isNotEmpty(location)) {
-        explorer.openLocation(location);
-      }
+    if (control instanceof VfsFileExplorer explorer
+        && !explorer.isDisposed()
+        && StringUtils.isNotEmpty(location)) {
+      explorer.openLocation(location);
     }
   }
 
@@ -144,7 +157,6 @@ public class VfsFileExplorerViews {
     VfsFileExplorer explorer = new VfsFileExplorer(container, hopGui);
     explorer.setLayoutData(new FormDataBuilder().fullSize().result());
     explorer.openLocation(location);
-    explorer.activate();
     return explorer;
   }
 
@@ -186,21 +198,21 @@ public class VfsFileExplorerViews {
     if (isDockOpen(hopGui)) {
       CTabItem item = dock.findToolTab(DOCK_TOOL_ID);
       dock.selectTab(item);
-      Control control = dock.getToolContent(item);
-      if (control instanceof VfsFileExplorer explorer && !explorer.isDisposed()) {
-        explorer.activate();
-      }
       return;
     }
-    openDock(hopGui, defaultDockLocation(hopGui));
+    openDock(hopGui, defaultLocation(hopGui.getVariables()));
   }
 
-  private static String defaultDockLocation(HopGui hopGui) {
+  /**
+   * Where a new explorer starts: the project home, or the user home without a project. On Hop Web
+   * the user home is the server's, so the project comes first.
+   */
+  public static String defaultLocation(IVariables variables) {
     try {
-      if (hopGui.getVariables() != null) {
-        String projectHome = hopGui.getVariables().getVariable("PROJECT_HOME");
+      if (variables != null) {
+        String projectHome = variables.getVariable("PROJECT_HOME");
         if (StringUtils.isNotEmpty(projectHome)) {
-          projectHome = hopGui.getVariables().resolve(projectHome);
+          projectHome = variables.resolve(projectHome);
           if (StringUtils.isNotEmpty(projectHome)) {
             return projectHome;
           }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorers.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorers.java
deleted file mode 100644
index 992c58c75c..0000000000
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorers.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * 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.vfs.explorer;
-
-import java.util.concurrent.atomic.AtomicReference;
-
-/** The file explorer the user last touched, so a toolbar contribution can find it. */
-public final class VfsFileExplorers {
-
-  private static final AtomicReference<VfsFileExplorer> ACTIVE = new AtomicReference<>();
-
-  private VfsFileExplorers() {}
-
-  public static void activate(VfsFileExplorer explorer) {
-    if (explorer != null) {
-      ACTIVE.set(explorer);
-    }
-  }
-
-  public static VfsFileExplorer active() {
-    VfsFileExplorer explorer = ACTIVE.get();
-    if (explorer == null || explorer.isDisposed()) {
-      return null;
-    }
-    return explorer;
-  }
-}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListing.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListing.java
index 2cbea700a4..671c1a224d 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListing.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListing.java
@@ -23,27 +23,48 @@ import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.search.SearchMatcher;
 import org.apache.hop.core.util.Utils;
 
-/** Lists one folder and presents those rows. One {@code getChildren()} call, no content reads. */
+/**
+ * Lists one folder and presents those rows. One {@code getChildren()} call, no content reads. The
+ * details of a large folder are read one page at a time.
+ */
 public final class VfsFileListing {
 
+  /** Rows read per page. A folder with more children shows "load more". */
+  public static final int PAGE_SIZE = 5000;
+
   private VfsFileListing() {}
 
+  /** All rows of {@code folder}. */
   public static List<VfsFileRow> childrenOf(FileObject folder) throws Exception {
+    FileObject[] children = listChildren(folder);
+    return rowsOf(children, 0, children.length);
+  }
+
+  /** The children of {@code folder}, without reading their details. */
+  public static FileObject[] listChildren(FileObject folder) throws Exception {
     FileObject[] children = folder.getChildren();
-    if (children == null || children.length == 0) {
+    return children == null ? new FileObject[0] : children;
+  }
+
+  /**
+   * Rows for {@code children[from..to)}. One unreadable child is left out. A missing library or a
+   * broken provider fails the whole page, and so does a page where no child could be read.
+   */
+  public static List<VfsFileRow> rowsOf(FileObject[] children, int from, int to) throws Exception {
+    if (children == null || from >= to) {
       return List.of();
     }
-    List<VfsFileRow> rows = new ArrayList<>(children.length);
+    int end = Math.min(to, children.length);
+    List<VfsFileRow> rows = new ArrayList<>(Math.max(0, end - from));
     Exception firstFailure = null;
-    for (FileObject child : children) {
+    for (int i = from; i < end; i++) {
+      FileObject child = children[i];
       if (child == null) {
         continue;
       }
       try {
         rows.add(VfsFileDetails.read(child));
       } catch (Exception e) {
-        // A missing library or a broken provider fails the whole folder. One unreadable child
-        // does not hide the rest.
         if (isDriverFailure(e)) {
           throw e;
         }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransfer.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransfer.java
index f8cf0a3154..ded80e272c 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransfer.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransfer.java
@@ -17,15 +17,26 @@
 
 package org.apache.hop.ui.hopgui.vfs.explorer;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CancellationException;
+import java.util.function.BooleanSupplier;
 import lombok.Getter;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.commons.vfs2.FileSystemException;
 import org.apache.commons.vfs2.Selectors;
 import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
 
-/** Copy and move of VFS files and folders. Refuses a copy onto itself or into its own child. */
+/**
+ * Copy and move of VFS files and folders. Refuses a copy onto itself or into its own child. An
+ * existing file is only replaced once its new content is completely written, and an existing folder
+ * is merged rather than replaced.
+ */
 public final class VfsFileTransfer {
 
   public enum Mode {
@@ -37,7 +48,55 @@ public final class VfsFileTransfer {
     NONE,
     SAME,
     INTO_ITSELF,
-    EXISTS
+    EXISTS,
+    /** A file where a folder of the same name exists, or the other way around. */
+    OTHER_KIND
+  }
+
+  /** What to do with a file that already exists at the destination. */
+  public enum Resolution {
+    REPLACE,
+    SKIP,
+    CANCEL
+  }
+
+  /** Counts what a transfer wrote. Called on the worker thread. */
+  public interface Progress {
+    Progress NONE =
+        new Progress() {
+          @Override
+          public void bytes(long count) {
+            // Not counted.
+          }
+
+          @Override
+          public void fileDone() {
+            // Not counted.
+          }
+        };
+
+    void bytes(long count);
+
+    void fileDone();
+  }
+
+  /** Decides about one existing destination file. Called on the worker thread. */
+  @FunctionalInterface
+  public interface ConflictResolver {
+    Resolution resolve(FileObject source, FileObject target);
+  }
+
+  /** One file or folder that could not be placed. Other entries are still transferred. */
+  @Getter
+  public static final class RefusedException extends RuntimeException {
+    private final Refusal refusal;
+    private final String name;
+
+    public RefusedException(Refusal refusal, String name) {
+      super(refusal + ": " + name);
+      this.refusal = refusal;
+      this.name = name;
+    }
   }
 
   /** One file or folder held on the explorer clipboard. */
@@ -54,6 +113,12 @@ public final class VfsFileTransfer {
     }
   }
 
+  private static final Class<?> PKG = VfsFileExplorer.class;
+
+  static final String TEMP_PREFIX = ".hop-transfer-";
+
+  private static final int BUFFER_SIZE = 64 * 1024;
+
   private VfsFileTransfer() {}
 
   /**
@@ -85,9 +150,127 @@ public final class VfsFileTransfer {
     return kept;
   }
 
+  /**
+   * Drop a path that sits inside another path of the list, and repeated paths. A path that holds
+   * another one is a folder, so this needs no file system access: the folder transfer already
+   * includes the rest.
+   */
+  public static List<String> withoutNestedPaths(List<String> paths) {
+    List<String> kept = new ArrayList<>();
+    if (paths == null) {
+      return kept;
+    }
+    for (int i = 0; i < paths.size(); i++) {
+      String path = paths.get(i);
+      if (path == null || path.isBlank()) {
+        continue;
+      }
+      boolean nested = false;
+      for (int j = 0; j < paths.size() && !nested; j++) {
+        String other = paths.get(j);
+        if (j == i || other == null || other.isBlank()) {
+          continue;
+        }
+        nested =
+            VfsLocations.isUnder(other, path) || (j < i && VfsLocations.sameLocation(other, path));
+      }
+      if (!nested) {
+        kept.add(path);
+      }
+    }
+    return kept;
+  }
+
+  /** Opens the file of an entry. */
+  @FunctionalInterface
+  public interface FileResolver {
+    FileObject resolve(String uri) throws Exception;
+  }
+
+  /** One entry of a batch that was not transferred: refused, or failed with an error. */
+  @Getter
+  public static final class Failure {
+    private final String name;
+
+    /** Why it was refused, or null when it failed with {@link #error}. */
+    private final Refusal refusal;
+
+    private final Exception error;
+
+    Failure(String name, Refusal refusal, Exception error) {
+      this.name = name;
+      this.refusal = refusal;
+      this.error = error;
+    }
+  }
+
+  /** What a copy or move of several entries did. */
+  @Getter
+  public static final class BatchResult {
+    private final List<Failure> failures = new ArrayList<>();
+
+    /**
+     * Entries whose source is still in place. After a move these are the ones that were skipped,
+     * refused, failed or not reached; after a copy, all of them.
+     */
+    private final List<Entry> remaining = new ArrayList<>();
+
+    private boolean cancelled;
+  }
+
+  /**
+   * Copy or move every entry into {@code destinationFolder}. A refusal or an error on one entry is
+   * recorded and the next entry is still transferred. A stop ends the batch.
+   */
+  public static BatchResult transferAll(
+      List<Entry> entries,
+      FileObject destinationFolder,
+      Mode mode,
+      ConflictResolver resolver,
+      BooleanSupplier cancelled,
+      Progress progress,
+      FileResolver files) {
+    BatchResult result = new BatchResult();
+    for (Entry entry : entries) {
+      if (isCancelled(cancelled)) {
+        result.cancelled = true;
+        break;
+      }
+      try {
+        transfer(
+            files.resolve(entry.getUri()), destinationFolder, mode, resolver, cancelled, progress);
+      } catch (RefusedException e) {
+        result.failures.add(new Failure(entry.getName(), e.getRefusal(), null));
+      } catch (CancellationException e) {
+        result.cancelled = true;
+        break;
+      } catch (Exception e) {
+        result.failures.add(new Failure(entry.getName(), null, e));
+      }
+    }
+    for (Entry entry : entries) {
+      if (mode == Mode.COPY || stillInPlace(files, entry)) {
+        result.remaining.add(entry);
+      }
+    }
+    return result;
+  }
+
+  /** True when the source of {@code entry} exists, or when that cannot be told. */
+  private static boolean stillInPlace(FileResolver files, Entry entry) {
+    try {
+      FileObject file = files.resolve(entry.getUri());
+      file.refresh();
+      return file.exists();
+    } catch (Exception e) {
+      return true;
+    }
+  }
+
   /**
    * Why this source cannot be placed in {@code destinationFolder} under its own base name. {@link
-   * Refusal#EXISTS} can still proceed when the caller chooses to replace.
+   * Refusal#EXISTS} can still proceed: a file is replaced when the resolver says so, a folder is
+   * merged.
    */
   public static Refusal refusal(FileObject source, FileObject destinationFolder)
       throws FileSystemException {
@@ -102,32 +285,149 @@ public final class VfsFileTransfer {
       return Refusal.INTO_ITSELF;
     }
     if (destination.exists()) {
-      return Refusal.EXISTS;
+      return destination.isFolder() == source.isFolder() ? Refusal.EXISTS : Refusal.OTHER_KIND;
     }
     return Refusal.NONE;
   }
 
   /**
    * Copy or move {@code source} into {@code destinationFolder}, keeping the base name. An existing
-   * destination is replaced only when {@code overwrite} is true.
+   * folder is merged. Each existing file goes to {@code resolver}.
+   *
+   * @throws RefusedException when the source cannot be placed at all
+   * @throws CancellationException when {@code cancelled} turns true or the resolver cancels
    */
   public static void transfer(
-      FileObject source, FileObject destinationFolder, Mode mode, boolean overwrite)
-      throws FileSystemException {
+      FileObject source,
+      FileObject destinationFolder,
+      Mode mode,
+      ConflictResolver resolver,
+      BooleanSupplier cancelled)
+      throws IOException {
+    transfer(source, destinationFolder, mode, resolver, cancelled, Progress.NONE);
+  }
+
+  /** As {@link #transfer(FileObject, FileObject, Mode, ConflictResolver, BooleanSupplier)}. */
+  public static void transfer(
+      FileObject source,
+      FileObject destinationFolder,
+      Mode mode,
+      ConflictResolver resolver,
+      BooleanSupplier cancelled,
+      Progress progress)
+      throws IOException {
     Refusal refusal = refusal(source, destinationFolder);
+    String name = source == null ? "" : source.getName().getBaseName();
     if (refusal == Refusal.SAME
         || refusal == Refusal.INTO_ITSELF
-        || (refusal == Refusal.EXISTS && !overwrite)) {
-      throw new IllegalStateException(refusal.name());
+        || refusal == Refusal.OTHER_KIND) {
+      throw new RefusedException(refusal, name);
     }
-    FileObject destination = destinationFolder.resolveFile(source.getName().getBaseName());
-    if (refusal == Refusal.EXISTS) {
-      delete(destination);
+    place(
+        source,
+        destinationFolder.resolveFile(name),
+        mode,
+        resolver,
+        cancelled,
+        progress == null ? Progress.NONE : progress);
+  }
+
+  private static void place(
+      FileObject source,
+      FileObject target,
+      Mode mode,
+      ConflictResolver resolver,
+      BooleanSupplier cancelled,
+      Progress progress)
+      throws IOException {
+    checkCancelled(cancelled);
+    if (!target.exists()) {
+      if (mode == Mode.MOVE && source.canRenameTo(target)) {
+        source.moveTo(target);
+        progress.fileDone();
+      } else {
+        copyTree(source, target, cancelled, progress);
+        if (mode == Mode.MOVE) {
+          delete(source);
+        }
+      }
+      return;
     }
+    if (source.isFolder() != target.isFolder()) {
+      throw new RefusedException(Refusal.OTHER_KIND, source.getName().getBaseName());
+    }
+    if (!source.isFolder()) {
+      Resolution resolution = resolver == null ? Resolution.SKIP : resolver.resolve(source, target);
+      if (resolution == Resolution.CANCEL) {
+        throw new CancellationException();
+      }
+      if (resolution == Resolution.REPLACE) {
+        replaceFile(source, target, mode, cancelled, progress);
+      }
+      return;
+    }
+    for (FileObject child : source.getChildren()) {
+      place(
+          child,
+          target.resolveFile(child.getName().getBaseName()),
+          mode,
+          resolver,
+          cancelled,
+          progress);
+    }
+    if (mode == Mode.MOVE && source.getChildren().length == 0) {
+      // Skipped files stay behind, and so does the folder that holds them.
+      source.delete();
+    }
+  }
+
+  /**
+   * Replace the existing file {@code target} with {@code source}. The new content goes to a hidden
+   * file next to the target first, so a failed or stopped copy leaves the original untouched.
+   */
+  public static void replaceFile(
+      FileObject source, FileObject target, Mode mode, BooleanSupplier cancelled)
+      throws IOException {
+    replaceFile(source, target, mode, cancelled, Progress.NONE);
+  }
+
+  /** As {@link #replaceFile(FileObject, FileObject, Mode, BooleanSupplier)}, counting bytes. */
+  public static void replaceFile(
+      FileObject source, FileObject target, Mode mode, BooleanSupplier cancelled, Progress progress)
+      throws IOException {
+    FileObject folder = target.getParent();
+    String id = UUID.randomUUID().toString();
+    FileObject temp = folder.resolveFile(TEMP_PREFIX + id + ".tmp");
+    // copyFile removes a partly written temp file itself.
+    copyFile(source, temp, cancelled, progress == null ? Progress.NONE : progress);
+    // Keep the original under a hidden name until the new copy has the real name. On an object
+    // store a rename is a copy and a delete, so it can fail half way.
+    FileObject backup = folder.resolveFile(TEMP_PREFIX + id + ".bak");
+    try {
+      target.moveTo(backup);
+    } catch (IOException | RuntimeException e) {
+      deleteQuietly(temp);
+      throw e;
+    }
+    try {
+      temp.moveTo(target);
+    } catch (IOException | RuntimeException e) {
+      if (restore(backup, target)) {
+        deleteQuietly(temp);
+        throw e;
+      }
+      throw new IOException(
+          BaseMessages.getString(
+              PKG,
+              "VfsFileExplorer.Transfer.Stranded",
+              target.getName().getBaseName(),
+              backup.getName().getBaseName(),
+              temp.getName().getBaseName()),
+          e);
+    }
+    deleteQuietly(backup);
     if (mode == Mode.MOVE) {
-      source.moveTo(destination);
-    } else {
-      destination.copyFrom(source, Selectors.SELECT_ALL);
+      source.delete();
     }
   }
 
@@ -171,4 +471,98 @@ public final class VfsFileTransfer {
     }
     return folder.getName().isDescendent(candidate.getName());
   }
+
+  /**
+   * Copy a file or a folder with everything in it to {@code target}, which does not exist yet.
+   * Stops between chunks when {@code cancelled} turns true, and removes a partly written file.
+   */
+  public static void copy(
+      FileObject source, FileObject target, BooleanSupplier cancelled, Progress progress)
+      throws IOException {
+    copyTree(source, target, cancelled, progress == null ? Progress.NONE : progress);
+  }
+
+  /** Put the original back after the new copy could not take its name. */
+  private static boolean restore(FileObject backup, FileObject target) {
+    try {
+      if (target.exists()) {
+        target.delete();
+      }
+      backup.moveTo(target);
+      return true;
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  /**
+   * A folder this creates is removed again when the copy fails or is stopped, with whatever was
+   * already copied into it.
+   */
+  private static void copyTree(
+      FileObject source, FileObject target, BooleanSupplier cancelled, Progress progress)
+      throws IOException {
+    checkCancelled(cancelled);
+    if (!source.isFolder()) {
+      copyFile(source, target, cancelled, progress);
+      return;
+    }
+    target.createFolder();
+    try {
+      for (FileObject child : source.getChildren()) {
+        copyTree(child, target.resolveFile(child.getName().getBaseName()), cancelled, progress);
+      }
+    } catch (IOException | RuntimeException e) {
+      try {
+        delete(target);
+      } catch (Exception ignored) {
+        // The failure itself is what gets reported.
+      }
+      throw e;
+    }
+  }
+
+  /**
+   * Copy one file's content, checking {@code cancelled} between chunks so Stop does not wait for a
+   * large file. A failed or stopped copy deletes what it wrote.
+   */
+  private static void copyFile(
+      FileObject source, FileObject target, BooleanSupplier cancelled, Progress progress)
+      throws IOException {
+    try (InputStream in = HopVfs.getInputStream(source);
+        OutputStream out = HopVfs.getOutputStream(target, false)) {
+      byte[] buffer = new byte[BUFFER_SIZE];
+      int read;
+      while ((read = in.read(buffer)) >= 0) {
+        checkCancelled(cancelled);
+        out.write(buffer, 0, read);
+        progress.bytes(read);
+      }
+    } catch (IOException | RuntimeException e) {
+      deleteQuietly(target);
+      throw e;
+    }
+    progress.fileDone();
+  }
+
+  private static void checkCancelled(BooleanSupplier cancelled) {
+    if (isCancelled(cancelled)) {
+      throw new CancellationException();
+    }
+  }
+
+  private static boolean isCancelled(BooleanSupplier cancelled) {
+    return (cancelled != null && cancelled.getAsBoolean())
+        || Thread.currentThread().isInterrupted();
+  }
+
+  private static void deleteQuietly(FileObject file) {
+    try {
+      if (file.exists()) {
+        file.delete();
+      }
+    } catch (Exception ignored) {
+      // Best effort: the original file is still in place.
+    }
+  }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsListingCounts.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsListingCounts.java
index 0e581978e7..f75757d626 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsListingCounts.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsListingCounts.java
@@ -38,17 +38,31 @@ public final class VfsListingCounts {
   private final int selected;
   private final long selectedBytes;
 
+  /** Children of the folder whose details are not read yet. */
+  private final int unread;
+
   public VfsListingCounts(int listed, long listedBytes, int selected, long selectedBytes) {
+    this(listed, listedBytes, selected, selectedBytes, 0);
+  }
+
+  public VfsListingCounts(
+      int listed, long listedBytes, int selected, long selectedBytes, int unread) {
     this.listed = listed;
     this.listedBytes = Math.max(0, listedBytes);
     this.selected = Math.max(0, selected);
     this.selectedBytes = Math.max(0, selectedBytes);
+    this.unread = Math.max(0, unread);
   }
 
   public static VfsListingCounts of(List<VfsFileRow> listed, List<VfsFileRow> selected) {
+    return of(listed, selected, 0);
+  }
+
+  public static VfsListingCounts of(
+      List<VfsFileRow> listed, List<VfsFileRow> selected, int unread) {
     List<VfsFileRow> rows = listed == null ? List.of() : listed;
     List<VfsFileRow> picked = selected == null ? List.of() : selected;
-    return new VfsListingCounts(rows.size(), bytes(rows), picked.size(), bytes(picked));
+    return new VfsListingCounts(rows.size(), bytes(rows), picked.size(), bytes(picked), unread);
   }
 
   /** Sum of known file sizes. Folders and missing sizes add nothing. */
@@ -89,15 +103,20 @@ public final class VfsListingCounts {
     return size + "B";
   }
 
-  /** {@code - 200 files (2.1GB) - 10 files selected (120MB)}, or empty when nothing is listed. */
+  /**
+   * {@code - 200 files (2.1GB) - 10 files selected (120MB)}, followed by the number not loaded yet
+   * when there is one, or empty when nothing is listed.
+   */
   public String statusSuffix() {
     if (listed < 0) {
       return "";
     }
-    return " - "
-        + phrase(listed, listedBytes, false)
-        + " - "
-        + phrase(selected, selectedBytes, true);
+    String suffix =
+        " - " + phrase(listed, listedBytes, false) + " - " + phrase(selected, selectedBytes, true);
+    if (unread > 0) {
+      suffix += " - " + BaseMessages.getString(PKG, "VfsFileExplorer.Status.Unread", unread);
+    }
+    return suffix;
   }
 
   private static String phrase(int count, long bytes, boolean selected) {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocations.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocations.java
index 9f313957f4..6fa0f3abca 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocations.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocations.java
@@ -57,14 +57,68 @@ public final class VfsLocations {
     return parent;
   }
 
+  /** True when both name the same folder, also when one is a plain path and the other a URI. */
+  public static boolean sameLocation(String left, String right) {
+    return normalize(left).equals(normalize(right));
+  }
+
   /** True when {@code childUri} is strictly inside {@code parentUri}. */
   public static boolean isUnder(String parentUri, String childUri) {
-    String parent = stripTrailingSeparator(parentUri);
-    String child = stripTrailingSeparator(childUri);
+    String parent = normalize(parentUri);
+    String child = normalize(childUri);
     if (parent.isEmpty() || child.isEmpty() || parent.equals(child)) {
       return false;
     }
-    return child.startsWith(parent + "/") || child.startsWith(parent + "\\");
+    return child.startsWith(parent.endsWith("/") ? parent : parent + "/");
+  }
+
+  /**
+   * {@code location} written relative to a variable, for example {@code
+   * ${PROJECT_HOME}/data/in.csv}.
+   *
+   * @param location the file or folder
+   * @param variable the variable name, without {@code ${}}
+   * @param value the resolved value of the variable
+   * @return the relative form, or null when {@code location} is not {@code value} or inside it
+   */
+  public static String relativeTo(String location, String variable, String value) {
+    if (location == null || location.isBlank() || value == null || value.isBlank()) {
+      return null;
+    }
+    String expression = "${" + variable + "}";
+    if (sameLocation(location, value)) {
+      return expression;
+    }
+    if (!isUnder(value, location)) {
+      return null;
+    }
+    String rest = normalize(location).substring(normalize(value).length());
+    return expression + (rest.startsWith("/") ? rest : "/" + rest);
+  }
+
+  /**
+   * A comparable form: a local path such as {@code /home/x} or {@code C:\\x} becomes the {@code
+   * file://} URI VFS reports for it, separators become {@code /}, and a trailing separator is
+   * dropped. Does not touch the file system.
+   */
+  static String normalize(String location) {
+    String value = stripTrailingSeparator(location == null ? "" : location.trim());
+    if (value.isEmpty()) {
+      return "";
+    }
+    if (value.length() >= 2
+        && Character.isLetter(value.charAt(0))
+        && value.charAt(1) == ':'
+        && (value.length() == 2 || value.charAt(2) == '\\' || value.charAt(2) == '/')) {
+      return "file:///" + value.replace('\\', '/');
+    }
+    if (value.startsWith("/")) {
+      return "file://" + value;
+    }
+    if (value.regionMatches(true, 0, "file:", 0, 5)) {
+      return value.replace('\\', '/');
+    }
+    return value;
   }
 
   public static String stripTrailingSeparator(String value) {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsPlaces.java b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsPlaces.java
new file mode 100644
index 0000000000..4a7897de58
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsPlaces.java
@@ -0,0 +1,155 @@
+/*
+ * 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.vfs.explorer;
+
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.IVfsBrowseLocation;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+
+/**
+ * The places listed next to the folder tree: the project, the named VFS connections, the local
+ * roots and the bookmarks. No SWT, so the lists can be tested headless.
+ */
+public final class VfsPlaces {
+
+  public enum Kind {
+    PROJECT,
+    CONNECTION,
+    LOCAL,
+    BOOKMARK
+  }
+
+  /** One entry in the places list. */
+  @Getter
+  public static final class Place {
+    private final Kind kind;
+    private final String name;
+    private final String uri;
+
+    /** Image of the metadata type for a connection, otherwise null. */
+    private final String image;
+
+    /** Loads {@link #image}: connection types live in plugins. */
+    private final ClassLoader classLoader;
+
+    public Place(Kind kind, String name, String uri, String image, ClassLoader classLoader) {
+      this.kind = kind;
+      this.name = name == null ? "" : name;
+      this.uri = uri == null ? "" : uri;
+      this.image = image;
+      this.classLoader = classLoader;
+    }
+  }
+
+  private static final Comparator<Place> BY_NAME =
+      Comparator.comparing(Place::getName, String.CASE_INSENSITIVE_ORDER);
+
+  private VfsPlaces() {}
+
+  /** The project home, when a project is enabled. */
+  public static List<Place> project(IVariables variables, String label) {
+    if (variables == null) {
+      return List.of();
+    }
+    String home = variables.getVariable("PROJECT_HOME");
+    if (StringUtils.isBlank(home)) {
+      return List.of();
+    }
+    return List.of(new Place(Kind.PROJECT, label, variables.resolve(home), null, null));
+  }
+
+  /**
+   * Every saved connection whose type can be browsed, sorted by name. A type that fails to load is
+   * logged and left out.
+   */
+  public static List<Place> connections(IHopMetadataProvider provider, IVariables variables) {
+    List<Place> places = new ArrayList<>();
+    if (provider == null) {
+      return places;
+    }
+    List<Class<IHopMetadata>> classes;
+    try {
+      classes = provider.getMetadataClasses();
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error listing metadata types for the VFS File Explorer", e);
+      return places;
+    }
+    for (Class<IHopMetadata> metadataClass : classes) {
+      if (!IVfsBrowseLocation.class.isAssignableFrom(metadataClass)) {
+        continue;
+      }
+      HopMetadata annotation = metadataClass.getAnnotation(HopMetadata.class);
+      String image = annotation == null ? null : annotation.image();
+      try {
+        IHopMetadataSerializer<IHopMetadata> serializer = provider.getSerializer(metadataClass);
+        for (IHopMetadata metadata : serializer.loadAll()) {
+          String root = ((IVfsBrowseLocation) metadata).getBrowseRoot(variables);
+          if (StringUtils.isNotBlank(root)) {
+            places.add(
+                new Place(
+                    Kind.CONNECTION,
+                    metadata.getName(),
+                    root,
+                    image,
+                    metadataClass.getClassLoader()));
+          }
+        }
+      } catch (Exception e) {
+        LogChannel.UI.logError(
+            "Error loading " + metadataClass.getSimpleName() + " connections for the explorer", e);
+      }
+    }
+    places.sort(BY_NAME);
+    return places;
+  }
+
+  /** The user home and the file system roots: drive letters on Windows, {@code /} elsewhere. */
+  public static List<Place> local(String homeLabel) {
+    List<Place> places = new ArrayList<>();
+    String home = System.getProperty("user.home");
+    if (StringUtils.isNotBlank(home)) {
+      places.add(new Place(Kind.LOCAL, homeLabel, home, null, null));
+    }
+    for (Path root : FileSystems.getDefault().getRootDirectories()) {
+      String path = root.toString();
+      places.add(new Place(Kind.LOCAL, path, path, null, null));
+    }
+    return places;
+  }
+
+  public static List<Place> bookmarks(Map<String, String> bookmarks) {
+    List<Place> places = new ArrayList<>();
+    if (bookmarks != null) {
+      bookmarks.forEach((name, uri) -> places.add(new Place(Kind.BOOKMARK, name, uri, null, null)));
+    }
+    places.sort(BY_NAME);
+    return places;
+  }
+}
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/vfs/explorer/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/vfs/explorer/messages/messages_en_US.properties
index 2867eec69c..cc2094deed 100644
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/vfs/explorer/messages/messages_en_US.properties
+++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/vfs/explorer/messages/messages_en_US.properties
@@ -24,12 +24,14 @@ VfsFileExplorer.Explore.NoName.Title=VFS File Explorer
 VfsFileExplorer.Explore.NoName.Message=Give the connection a name and save it before exploring it.
 VfsFileExplorer.Explore.Error.Title=Explore
 VfsFileExplorer.Explore.Error.Message=The VFS location could not be opened.
+VfsFileExplorer.Explore.Save.Title=Save connection
+VfsFileExplorer.Explore.Save.Message=The explorer opens the saved connection. Save your changes and explore it?
 VfsFileExplorer.Toolbar.NewTab.Tooltip=Open a new location tab for the folder you are viewing. Ctrl+T does the same (Cmd+T on macOS).
 VfsFileExplorer.Toolbar.Dock.Tooltip=Open this location in the bottom panel
 VfsFileExplorer.Toolbar.Undock.Tooltip=Open this location in a window
 VfsFileExplorer.Location.Go=Go
 VfsFileExplorer.Location.Go.Tooltip=Open the location in the text field. Variables such as '${PROJECT_HOME}' are resolved.
-VfsFileExplorer.Navigate.Home.Tooltip=Go to the user home folder
+VfsFileExplorer.Navigate.Home.Tooltip=Go to the project home, or the user home without a project
 VfsFileExplorer.Navigate.Up.Tooltip=Go to the parent folder
 VfsFileExplorer.Navigate.Back.Tooltip=Go back
 VfsFileExplorer.Navigate.Forward.Tooltip=Go forward
@@ -43,19 +45,30 @@ VfsFileExplorer.Tree.Rename.Tooltip=Rename the selected folder
 VfsFileExplorer.Tree.Hidden.Tooltip=Show or hide hidden folders
 VfsFileExplorer.Details.Open.Tooltip=Open
 VfsFileExplorer.Details.OpenText.Tooltip=Open as text file
-VfsFileExplorer.Details.Download.Tooltip=Download to a folder you choose
-VfsFileExplorer.Details.Upload.Tooltip=Upload the selected files to a folder
+VfsFileExplorer.Details.Download.Tooltip=Save a copy of the selected file to a location you choose
+VfsFileExplorer.Details.Upload.Tooltip=Copy the selected files to a folder you choose
+VfsFileExplorer.Details.Download.Web.Tooltip=Download the selected file to your computer
+VfsFileExplorer.Details.Upload.Web.Tooltip=Upload a file from your computer into this folder
 VfsFileExplorer.Details.Drill.Tooltip=Browse into this archive
 VfsFileExplorer.Details.Copy.Tooltip=Copy the selection
 VfsFileExplorer.Details.Cut.Tooltip=Cut the selection
 VfsFileExplorer.Details.Paste.Tooltip=Paste into the folder you are viewing
 VfsFileExplorer.Details.Rename.Tooltip=Rename the selected file
 VfsFileExplorer.Details.Delete.Tooltip=Delete the selection
+VfsFileExplorer.Details.More.Tooltip=Load the next entries of a large folder
 VfsFileExplorer.Details.Hidden.Tooltip=Show or hide hidden files
 VfsFileExplorer.Details.Columns.Tooltip=Choose which file details to show
 VfsFileExplorer.Filter.Placeholder=Filter
 VfsFileExplorer.Bookmark.Add.Tooltip=Bookmark the selection
 VfsFileExplorer.Bookmark.Remove.Tooltip=Remove the selected bookmark
+VfsFileExplorer.Places.Refresh.Tooltip=Read the connections again
+VfsFileExplorer.Places.Project=Project
+VfsFileExplorer.Places.Home=Project home
+VfsFileExplorer.Places.Connections=Connections
+VfsFileExplorer.Places.Local=This computer
+VfsFileExplorer.Places.Server=Server
+VfsFileExplorer.Places.UserHome=Home
+VfsFileExplorer.Places.Bookmarks=Bookmarks
 VfsFileExplorer.Bookmark.Name.Header=Bookmark
 VfsFileExplorer.Bookmark.Name.Message=Name of the bookmark
 VfsFileExplorer.Column.NAME=Name
@@ -73,6 +86,7 @@ VfsFileExplorer.Delete.Message=Delete ''{0}''?
 VfsFileExplorer.Delete.Message.Many=Delete {0} items?
 VfsFileExplorer.Error.Title=VFS File Explorer
 VfsFileExplorer.Error.Bookmark=Could not save bookmarks
+VfsFileExplorer.Error.TooManyRunning=More than {0} operations are still running. Stop or wait for some of them, or check whether a server stopped responding.
 VfsFileExplorer.Operation.Listing=Listing {0}
 VfsFileExplorer.Operation.Opening=Opening {0}
 VfsFileExplorer.Operation.Deleting=Deleting {0}
@@ -82,16 +96,20 @@ VfsFileExplorer.Operation.Copying=Copying {0}
 VfsFileExplorer.Operation.Moving=Moving {0}
 VfsFileExplorer.Operation.Downloading=Downloading {0}
 VfsFileExplorer.Operation.Uploading=Uploading {0}
-VfsFileExplorer.Operation.Uploading.Many=Uploading {0} files
+VfsFileExplorer.Operation.Copying.Many=Copying {0} files
 VfsFileExplorer.Menu.Copy=Copy
 VfsFileExplorer.Menu.Cut=Cut
 VfsFileExplorer.Menu.Paste=Paste
 VfsFileExplorer.Menu.Rename=Rename
 VfsFileExplorer.Menu.Delete=Delete
+VfsFileExplorer.Menu.CopyPath=Copy path
+VfsFileExplorer.Menu.CopyProjectPath=Copy path as '${PROJECT_HOME}/...'
 VfsFileExplorer.Menu.Open=Open
 VfsFileExplorer.Menu.OpenText=Open as text file
-VfsFileExplorer.Menu.Download=Download
-VfsFileExplorer.Menu.Upload=Upload
+VfsFileExplorer.Menu.Download=Save a copy as...
+VfsFileExplorer.Menu.Upload=Copy to folder...
+VfsFileExplorer.Menu.Download.Web=Download
+VfsFileExplorer.Menu.Upload.Web=Upload to this folder
 VfsFileExplorer.Upload.Message=Choose the folder to place the selected files in.
 VfsFileExplorer.Menu.Drill=Browse into archive
 VfsFileExplorer.Menu.OpenLocation=Open in VFS File Explorer
@@ -100,14 +118,23 @@ VfsFileExplorer.Download.AllFiles=All files
 VfsFileExplorer.Transfer.Same=Cannot copy or move ''{0}'' onto itself
 VfsFileExplorer.Transfer.IntoItself=Cannot copy or move ''{0}'' into itself
 VfsFileExplorer.Transfer.Exists=''{0}'' already exists
-VfsFileExplorer.Paste.Overwrite.Title=Replace files
-VfsFileExplorer.Paste.Overwrite.Message=Replace existing files in this folder?
+VfsFileExplorer.Transfer.OtherKind=Cannot replace ''{0}'': one is a file and the other is a folder
+VfsFileExplorer.Transfer.Failed=Could not copy or move ''{0}'': {1}
+VfsFileExplorer.Transfer.Stranded=''{0}'' could not be replaced. In the same folder, the original is kept as the hidden file ''{1}'' and the new copy as ''{2}''.
+VfsFileExplorer.Conflict.Title=File already exists
+VfsFileExplorer.Conflict.Message=''{0}'' already exists in {1}.\nReplace it?
+VfsFileExplorer.Conflict.Replace=Replace
+VfsFileExplorer.Conflict.ReplaceAll=Replace all
+VfsFileExplorer.Conflict.Skip=Skip
+VfsFileExplorer.Conflict.SkipAll=Skip all
+VfsFileExplorer.Conflict.Cancel=Cancel
 VfsFileExplorer.Delete.NoParent=Cannot delete ''{0}'' because it has no parent folder
 VfsFileExplorer.Rename.NoParent=Cannot rename ''{0}'' because it has no parent folder
 VfsFileExplorer.Status.Listed.One=1 file ({0})
 VfsFileExplorer.Status.Listed.Many={0} files ({1})
 VfsFileExplorer.Status.Selected.One=1 file selected ({0})
 VfsFileExplorer.Status.Selected.Many={0} files selected ({1})
+VfsFileExplorer.Status.Unread={0} more not loaded
 VfsFileExplorer.Operations.Column.Description=Description
 VfsFileExplorer.Operations.Column.Location=Location
 VfsFileExplorer.Operations.Column.Status=Status
@@ -117,6 +144,7 @@ VfsFileExplorer.Operations.Status.Running=Running
 VfsFileExplorer.Operations.Status.Done=Done
 VfsFileExplorer.Operations.Status.Failed=Failed
 VfsFileExplorer.Operations.Status.Cancelled=Cancelled
+VfsFileExplorer.Operations.Progress={0} files - {1}
 VfsFileExplorer.Operations.Stop.Tooltip=Stop the current operation
 VfsFileExplorer.Operations.Expand.Tooltip=Show operation details
 VfsFileExplorer.Operations.Minimize.Tooltip=Hide operation details
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerStatusTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerStatusTest.java
index c18b1fef04..c07f303cab 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerStatusTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsExplorerStatusTest.java
@@ -101,6 +101,9 @@ class VfsExplorerStatusTest {
   void oneRowUsesTheSingular() {
     VfsListingCounts counts = new VfsListingCounts(1, 5, 1, 5);
     assertEquals(" - 1 file (5B) - 1 file selected (5B)", counts.statusSuffix());
+    assertEquals(
+        " - 1 file (5B) - 1 file selected (5B) - 3 more not loaded",
+        new VfsListingCounts(1, 5, 1, 5, 3).statusSuffix());
   }
 
   @Test
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListingTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListingTest.java
index 5e454aa6df..d046907ec6 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListingTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileListingTest.java
@@ -52,6 +52,22 @@ class VfsFileListingTest {
     HopVfs.reset();
   }
 
+  @Test
+  void aLargeFolderIsReadOnePageAtATime() throws Exception {
+    FileObject folder = HopVfs.getFileObject("ram:///vfs-explorer-pages/");
+    folder.createFolder();
+    for (int i = 0; i < 5; i++) {
+      folder.resolveFile("file" + i + ".txt").createFile();
+    }
+    folder.refresh();
+
+    FileObject[] children = VfsFileListing.listChildren(folder);
+    assertEquals(5, children.length);
+    assertEquals(2, VfsFileListing.rowsOf(children, 0, 2).size());
+    assertEquals(3, VfsFileListing.rowsOf(children, 2, 10).size());
+    assertTrue(VfsFileListing.rowsOf(children, 5, 7).isEmpty());
+  }
+
   @Test
   void ramFileHasNameExtensionAndSizeButNoOwner() throws Exception {
     FileObject folder = HopVfs.getFileObject("ram:///vfs-explorer/");
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransferTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransferTest.java
index b144546fee..ad41c409c2 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransferTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransferTest.java
@@ -19,41 +19,53 @@ package org.apache.hop.ui.hopgui.vfs.explorer;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.InputStream;
 import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CancellationException;
 import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.FileSystemException;
 import org.apache.hop.core.vfs.HopVfs;
 import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 class VfsFileTransferTest {
 
+  private static final VfsFileTransfer.ConflictResolver REPLACE =
+      (source, target) -> VfsFileTransfer.Resolution.REPLACE;
+  private static final VfsFileTransfer.ConflictResolver SKIP =
+      (source, target) -> VfsFileTransfer.Resolution.SKIP;
+
+  private FileObject root;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    root = HopVfs.getFileObject("ram:///vfs-transfer/");
+    root.delete(org.apache.commons.vfs2.Selectors.SELECT_ALL);
+    root.createFolder();
+  }
+
   @AfterEach
   void tearDown() {
-    VfsFileClipboard.clear();
     HopVfs.reset();
   }
 
   @Test
   void copyAndMoveKeepTheBaseNameAndRefuseAFolderIntoItself() throws Exception {
-    FileObject root = HopVfs.getFileObject("ram:///vfs-transfer/");
-    root.createFolder();
-    FileObject sourceDir = root.resolveFile("source");
-    sourceDir.createFolder();
-    FileObject file = sourceDir.resolveFile("notes.txt");
-    try (OutputStream out = file.getContent().getOutputStream()) {
-      out.write("hello".getBytes(StandardCharsets.UTF_8));
-    }
-    FileObject nested = sourceDir.resolveFile("nested");
-    nested.createFolder();
-    FileObject dest = root.resolveFile("dest");
-    dest.createFolder();
+    FileObject sourceDir = folder(root, "source");
+    FileObject file = write(sourceDir, "notes.txt", "hello");
+    FileObject nested = folder(sourceDir, "nested");
+    FileObject dest = folder(root, "dest");
 
     assertEquals(VfsFileTransfer.Refusal.NONE, VfsFileTransfer.refusal(file, dest));
-    VfsFileTransfer.transfer(file, dest, VfsFileTransfer.Mode.COPY, false);
+    VfsFileTransfer.transfer(file, dest, VfsFileTransfer.Mode.COPY, SKIP, null);
     assertTrue(dest.resolveFile("notes.txt").exists());
     assertTrue(file.exists());
 
@@ -61,11 +73,252 @@ class VfsFileTransferTest {
     assertEquals(VfsFileTransfer.Refusal.INTO_ITSELF, VfsFileTransfer.refusal(sourceDir, nested));
     assertEquals(VfsFileTransfer.Refusal.EXISTS, VfsFileTransfer.refusal(file, dest));
 
-    VfsFileTransfer.transfer(file, dest, VfsFileTransfer.Mode.MOVE, true);
+    VfsFileTransfer.transfer(file, dest, VfsFileTransfer.Mode.MOVE, REPLACE, null);
     assertFalse(file.exists());
     assertTrue(dest.resolveFile("notes.txt").exists());
   }
 
+  @Test
+  void replaceWritesTheNewContentAndLeavesNoTempFile() throws Exception {
+    FileObject file = write(folder(root, "a"), "data.csv", "new");
+    FileObject dest = folder(root, "b");
+    write(dest, "data.csv", "old");
+
+    VfsFileTransfer.transfer(file, dest, VfsFileTransfer.Mode.COPY, REPLACE, null);
+
+    assertEquals("new", read(dest.resolveFile("data.csv")));
+    assertEquals(List.of("data.csv"), names(dest));
+  }
+
+  @Test
+  void aFailedReplaceKeepsTheOriginal() throws Exception {
+    FileObject dest = folder(root, "b");
+    FileObject target = write(dest, "data.csv", "old");
+    FileObject missing = root.resolveFile("a/missing.csv");
+
+    assertThrows(
+        FileSystemException.class,
+        () -> VfsFileTransfer.replaceFile(missing, target, VfsFileTransfer.Mode.COPY, null));
+
+    assertEquals("old", read(target));
+    assertEquals(List.of("data.csv"), names(dest));
+  }
+
+  @Test
+  void aStoppedReplaceKeepsTheOriginal() throws Exception {
+    FileObject source = write(folder(root, "a"), "data.csv", "new");
+    FileObject dest = folder(root, "b");
+    FileObject target = write(dest, "data.csv", "old");
+
+    assertThrows(
+        CancellationException.class,
+        () -> VfsFileTransfer.replaceFile(source, target, VfsFileTransfer.Mode.MOVE, () -> true));
+
+    assertEquals("old", read(target));
+    assertTrue(source.exists());
+    assertEquals(List.of("data.csv"), names(dest));
+  }
+
+  @Test
+  void aFolderIsMergedNotReplaced() throws Exception {
+    FileObject source = folder(root, "data");
+    write(source, "new.txt", "new");
+    write(source, "both.txt", "from source");
+    FileObject dest = folder(root, "dest");
+    FileObject existing = folder(dest, "data");
+    write(existing, "keep.txt", "keep");
+    write(existing, "both.txt", "from dest");
+
+    List<String> asked = new ArrayList<>();
+    VfsFileTransfer.transfer(
+        source,
+        dest,
+        VfsFileTransfer.Mode.COPY,
+        (from, to) -> {
+          asked.add(to.getName().getBaseName());
+          return VfsFileTransfer.Resolution.SKIP;
+        },
+        null);
+
+    assertEquals(List.of("both.txt"), asked);
+    assertEquals(List.of("both.txt", "keep.txt", "new.txt"), names(existing));
+    assertEquals("from dest", read(existing.resolveFile("both.txt")));
+    assertEquals("keep", read(existing.resolveFile("keep.txt")));
+  }
+
+  @Test
+  void aMergedMoveKeepsSkippedFilesInTheSource() throws Exception {
+    FileObject source = folder(root, "data");
+    write(source, "new.txt", "new");
+    write(source, "both.txt", "from source");
+    FileObject dest = folder(root, "dest");
+    write(folder(dest, "data"), "both.txt", "from dest");
+
+    VfsFileTransfer.transfer(source, dest, VfsFileTransfer.Mode.MOVE, SKIP, null);
+
+    assertEquals(List.of("both.txt"), names(source));
+    assertEquals(List.of("both.txt", "new.txt"), names(dest.resolveFile("data")));
+
+    VfsFileTransfer.transfer(source, dest, VfsFileTransfer.Mode.MOVE, REPLACE, null);
+    assertFalse(source.exists());
+    assertEquals("from source", read(dest.resolveFile("data/both.txt")));
+  }
+
+  @Test
+  void cancelInThePromptStopsTheTransfer() throws Exception {
+    FileObject file = write(folder(root, "a"), "data.csv", "new");
+    FileObject dest = folder(root, "b");
+    write(dest, "data.csv", "old");
+
+    assertThrows(
+        CancellationException.class,
+        () ->
+            VfsFileTransfer.transfer(
+                file,
+                dest,
+                VfsFileTransfer.Mode.COPY,
+                (source, target) -> VfsFileTransfer.Resolution.CANCEL,
+                null));
+    assertEquals("old", read(dest.resolveFile("data.csv")));
+  }
+
+  @Test
+  void aFileDoesNotReplaceAFolder() throws Exception {
+    FileObject file = write(folder(root, "a"), "data", "file");
+    FileObject dest = folder(root, "b");
+    folder(dest, "data");
+
+    assertEquals(VfsFileTransfer.Refusal.OTHER_KIND, VfsFileTransfer.refusal(file, dest));
+    VfsFileTransfer.RefusedException refused =
+        assertThrows(
+            VfsFileTransfer.RefusedException.class,
+            () -> VfsFileTransfer.transfer(file, dest, VfsFileTransfer.Mode.COPY, REPLACE, null));
+    assertEquals(VfsFileTransfer.Refusal.OTHER_KIND, refused.getRefusal());
+    assertTrue(dest.resolveFile("data").isFolder());
+  }
+
+  @Test
+  void progressCountsFilesAndBytes() throws Exception {
+    FileObject source = folder(root, "data");
+    write(source, "a.txt", "12345");
+    write(folder(source, "sub"), "b.txt", "123");
+    FileObject dest = folder(root, "dest");
+
+    VfsExplorerOperation operation = new VfsExplorerOperation("copy", "");
+    VfsFileTransfer.transfer(
+        source, dest, VfsFileTransfer.Mode.COPY, SKIP, null, operation.progress());
+
+    assertEquals(2, operation.getFilesDone());
+    assertEquals(8L, operation.getBytesDone());
+    assertEquals("123", read(dest.resolveFile("data/sub/b.txt")));
+  }
+
+  @Test
+  void aStoppedCopyLeavesNoPartialFile() throws Exception {
+    FileObject source = write(folder(root, "a"), "big.bin", "x".repeat(200_000));
+    FileObject target = root.resolveFile("b/big.bin");
+    folder(root, "b");
+    int[] chunks = {0};
+
+    assertThrows(
+        CancellationException.class,
+        () ->
+            VfsFileTransfer.copy(
+                source, target, () -> ++chunks[0] > 1, VfsFileTransfer.Progress.NONE));
+
+    assertFalse(target.exists());
+  }
+
+  @Test
+  void aStoppedFolderCopyRemovesTheFolderItCreated() throws Exception {
+    FileObject source = folder(root, "data");
+    write(source, "a.txt", "first");
+    write(source, "b.txt", "second");
+    FileObject dest = folder(root, "dest");
+    int[] checks = {0};
+
+    assertThrows(
+        CancellationException.class,
+        () ->
+            VfsFileTransfer.transfer(
+                source, dest, VfsFileTransfer.Mode.COPY, SKIP, () -> ++checks[0] > 4, null));
+
+    assertEquals(List.of(), names(dest));
+  }
+
+  @Test
+  void aCutWithSkipsAndRefusalsMovesOnlyTheRest() throws Exception {
+    FileObject source = folder(root, "source");
+    FileObject skipped = write(source, "skipped.txt", "new");
+    FileObject moved = write(source, "moved.txt", "moved");
+    FileObject dest = folder(root, "dest");
+    write(dest, "skipped.txt", "old");
+    List<VfsFileTransfer.Entry> entries =
+        List.of(
+            entry(skipped, false),
+            entry(dest, true), // into itself: refused
+            entry(root.resolveFile("source/missing.txt"), false), // fails
+            entry(moved, false));
+
+    VfsFileTransfer.BatchResult result =
+        VfsFileTransfer.transferAll(
+            entries, dest, VfsFileTransfer.Mode.MOVE, SKIP, null, null, HopVfs::getFileObject);
+
+    assertFalse(result.isCancelled());
+    assertEquals(2, result.getFailures().size());
+    assertEquals(VfsFileTransfer.Refusal.INTO_ITSELF, result.getFailures().get(0).getRefusal());
+    assertEquals("missing.txt", result.getFailures().get(1).getName());
+    assertTrue(result.getFailures().get(1).getError() != null);
+    // The moved file is gone from the source, and so is the one that was never there. The skipped
+    // and refused entries stay on the clipboard, so the cut can be finished.
+    assertEquals(
+        List.of("skipped.txt", "dest"),
+        result.getRemaining().stream().map(VfsFileTransfer.Entry::getName).toList());
+    assertEquals("old", read(dest.resolveFile("skipped.txt")));
+    assertEquals("moved", read(dest.resolveFile("moved.txt")));
+    assertFalse(moved.exists());
+
+    VfsFileClipboard clipboard = VfsFileClipboard.of(null);
+    clipboard.set(VfsFileTransfer.Mode.MOVE, entries);
+    clipboard.retain(clipboard.entries(), result.getRemaining());
+    assertEquals(2, clipboard.entries().size());
+    assertEquals(VfsFileTransfer.Mode.MOVE, clipboard.mode());
+  }
+
+  @Test
+  void aStoppedBatchKeepsWhatWasNotMoved() throws Exception {
+    FileObject source = folder(root, "source");
+    FileObject first = write(source, "1.txt", "1");
+    FileObject second = write(source, "2.txt", "2");
+    FileObject dest = folder(root, "dest");
+    List<VfsFileTransfer.Entry> entries = List.of(entry(first, false), entry(second, false));
+    int[] checks = {0};
+
+    VfsFileTransfer.BatchResult result =
+        VfsFileTransfer.transferAll(
+            entries,
+            dest,
+            VfsFileTransfer.Mode.MOVE,
+            SKIP,
+            () -> ++checks[0] > 3,
+            null,
+            HopVfs::getFileObject);
+
+    assertTrue(result.isCancelled());
+    assertFalse(first.exists());
+    assertEquals(
+        List.of("2.txt"),
+        result.getRemaining().stream().map(VfsFileTransfer.Entry::getName).toList());
+  }
+
+  @Test
+  void aDroppedPathInsideAnotherIsLeftOut() {
+    assertEquals(
+        List.of("/data", "/other.txt"),
+        VfsFileTransfer.withoutNestedPaths(
+            List.of("/data/in/a.txt", "/data", "/other.txt", "file:///data/in", "/other.txt")));
+  }
+
   @Test
   void nestedSelectionKeepsTheParentOnly() {
     VfsFileTransfer.Entry parent = new VfsFileTransfer.Entry("ram:///a", "a", true);
@@ -74,4 +327,53 @@ class VfsFileTransferTest {
     List<VfsFileTransfer.Entry> kept = VfsFileTransfer.withoutNested(List.of(child, parent, other));
     assertEquals(List.of("a", "c.txt"), kept.stream().map(VfsFileTransfer.Entry::getName).toList());
   }
+
+  @Test
+  void withoutADisplayEachClipboardIsItsOwn() {
+    VfsFileClipboard first = VfsFileClipboard.of(null);
+    first.set(
+        VfsFileTransfer.Mode.MOVE,
+        List.of(new VfsFileTransfer.Entry("ram:///a.txt", "a.txt", false)));
+    VfsFileClipboard second = VfsFileClipboard.of(null);
+    assertNotSame(first, second);
+    assertTrue(second.isEmpty());
+    assertFalse(first.isEmpty());
+  }
+
+  private static VfsFileTransfer.Entry entry(FileObject file, boolean folder) {
+    return new VfsFileTransfer.Entry(
+        HopVfs.getFilename(file), file.getName().getBaseName(), folder);
+  }
+
+  private static FileObject folder(FileObject parent, String name) throws Exception {
+    FileObject folder = parent.resolveFile(name);
+    folder.createFolder();
+    return folder;
+  }
+
+  private static FileObject write(FileObject parent, String name, String text) throws Exception {
+    FileObject file = parent.resolveFile(name);
+    try (OutputStream out = file.getContent().getOutputStream()) {
+      out.write(text.getBytes(StandardCharsets.UTF_8));
+    }
+    return file;
+  }
+
+  private static String read(FileObject file) throws Exception {
+    try (InputStream in = file.getContent().getInputStream()) {
+      return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+    } finally {
+      file.close();
+    }
+  }
+
+  private static List<String> names(FileObject folder) throws Exception {
+    folder.refresh();
+    List<String> names = new ArrayList<>();
+    for (FileObject child : folder.getChildren()) {
+      names.add(child.getName().getBaseName());
+    }
+    names.sort(String::compareTo);
+    return names;
+  }
 }
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocationsTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocationsTest.java
index cb68279631..b15e5a491f 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocationsTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsLocationsTest.java
@@ -52,4 +52,29 @@ class VfsLocationsTest {
     assertFalse(VfsLocations.isUnder("ram:///a", "ram:///a"));
     assertFalse(VfsLocations.isUnder("ram:///a/", "ram:///a"));
   }
+
+  @Test
+  void aPlainPathAndItsFileUriAreTheSameLocation() {
+    assertTrue(VfsLocations.sameLocation("/home/x", "file:///home/x/"));
+    assertTrue(VfsLocations.sameLocation("C:\\data\\in", "file:///C:/data/in"));
+    assertTrue(VfsLocations.sameLocation("C:\\", "file:///C:/"));
+    assertFalse(VfsLocations.sameLocation("/home/x", "file:///home/xy"));
+    assertFalse(VfsLocations.sameLocation("/home/x", "s3:///home/x"));
+    assertTrue(VfsLocations.isUnder("/home/x", "file:///home/x/sub"));
+    assertTrue(VfsLocations.isUnder("file:///home/x", "/home/x/sub/deeper"));
+    assertFalse(VfsLocations.isUnder("/home/x", "file:///home/xy"));
+  }
+
+  @Test
+  void aPathInsideTheProjectIsWrittenWithTheVariable() {
+    assertEquals(
+        "${PROJECT_HOME}/data/in.csv",
+        VfsLocations.relativeTo("/home/p/data/in.csv", "PROJECT_HOME", "/home/p/"));
+    assertEquals(
+        "${PROJECT_HOME}/data",
+        VfsLocations.relativeTo("file:///home/p/data", "PROJECT_HOME", "/home/p"));
+    assertEquals("${PROJECT_HOME}", VfsLocations.relativeTo("/home/p", "PROJECT_HOME", "/home/p"));
+    assertNull(VfsLocations.relativeTo("/home/pq/x", "PROJECT_HOME", "/home/p"));
+    assertNull(VfsLocations.relativeTo("s3:///bucket/x", "PROJECT_HOME", "/home/p"));
+  }
 }
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsPlacesTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsPlacesTest.java
new file mode 100644
index 0000000000..82fb420118
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsPlacesTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.vfs.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.util.List;
+import java.util.Map;
+import org.apache.hop.core.variables.Variables;
+import org.eclipse.swt.dnd.DND;
+import org.junit.jupiter.api.Test;
+
+class VfsPlacesTest {
+
+  @Test
+  void theProjectIsListedOnlyWhenAProjectIsEnabled() {
+    Variables variables = new Variables();
+    assertTrue(VfsPlaces.project(variables, "Project home").isEmpty());
+
+    variables.setVariable("PROJECT_HOME", "/work/p");
+    List<VfsPlaces.Place> places = VfsPlaces.project(variables, "Project home");
+    assertEquals(1, places.size());
+    assertEquals("/work/p", places.get(0).getUri());
+    assertEquals(VfsPlaces.Kind.PROJECT, places.get(0).getKind());
+  }
+
+  @Test
+  void bookmarksAreSortedByName() {
+    List<VfsPlaces.Place> places =
+        VfsPlaces.bookmarks(Map.of("zeta", "/z", "Alpha", "/a", "beta", "s3:///b"));
+    assertEquals(
+        List.of("Alpha", "beta", "zeta"), places.stream().map(VfsPlaces.Place::getName).toList());
+  }
+
+  @Test
+  void localPlacesStartWithTheUserHome() {
+    List<VfsPlaces.Place> places = VfsPlaces.local("Home");
+    assertFalse(places.isEmpty());
+    assertEquals(System.getProperty("user.home"), places.get(0).getUri());
+  }
+
+  @Test
+  void withoutAMetadataProviderThereAreNoConnections() {
+    assertTrue(VfsPlaces.connections(null, new Variables()).isEmpty());
+  }
+
+  @Test
+  void aDropWithoutAModifierCopies() {
+    int both = DND.DROP_COPY | DND.DROP_MOVE;
+    // The desktop: no modifier, Ctrl, and the move modifier.
+    assertEquals(
+        DND.DROP_COPY, VfsFileExplorerLocation.dropOperation(DND.DROP_DEFAULT, both, false));
+    assertEquals(DND.DROP_COPY, VfsFileExplorerLocation.dropOperation(DND.DROP_COPY, both, false));
+    assertEquals(DND.DROP_MOVE, VfsFileExplorerLocation.dropOperation(DND.DROP_MOVE, both, false));
+    // Hop Web reports no modifier as a move: always a copy there.
+    assertEquals(DND.DROP_COPY, VfsFileExplorerLocation.dropOperation(DND.DROP_MOVE, both, true));
+    assertEquals(
+        DND.DROP_NONE, VfsFileExplorerLocation.dropOperation(DND.DROP_MOVE, DND.DROP_MOVE, true));
+  }
+
+  @Test
+  void aDropIsRefusedOntoItselfItsChildOrItsOwnFolder() {
+    List<String> dragged = List.of("/data/in", "/data/notes.txt");
+    assertFalse(VfsFileExplorerLocation.canDropInto(dragged, "/data/in"));
+    assertFalse(VfsFileExplorerLocation.canDropInto(dragged, "file:///data/in/sub"));
+    assertFalse(VfsFileExplorerLocation.canDropInto(dragged, "/data"));
+    assertTrue(VfsFileExplorerLocation.canDropInto(dragged, "/archive"));
+    assertTrue(VfsFileExplorerLocation.canDropInto(List.of(), "/data"));
+  }
+}