mattcasters commented on code in PR #8876:
URL: https://github.com/apache/hop/pull/8876#discussion_r4249059642


##########
ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java:
##########
@@ -302,10 +359,171 @@ public VfsFileExplorerLocation(Composite parent, 
VfsFileExplorer explorer) {
           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 unless a 
move is asked for:
+   * the source and target are often different stores.
+   */
+  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));
+
+    DropTarget treeTarget = new DropTarget(tree, DND.DROP_COPY | 
DND.DROP_MOVE);
+    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, DND.DROP_COPY | 
DND.DROP_MOVE);
+    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) {
+        List<String> uris = urisOf(selection.get());
+        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) {
+        if (event.detail == DND.DROP_DEFAULT) {

Review Comment:
   **[bug]** An unmodified drop moves the files and deletes the originals on 
Linux and macOS, which contradicts the manual ("A drop copies the files: the 
originals stay where they are") and the comment above `installDragAndDrop`. 
`dragEnter` only rewrites `DND.DROP_DEFAULT` to copy. GTK and Cocoa turn that 
default into `DROP_MOVE` before the listener runs whenever the drop target 
style is `DROP_COPY | DROP_MOVE` and does not include `DROP_DEFAULT` 
(`DropTarget.setEventData`). No modifier key is held, so `event.detail` is 
already `DROP_MOVE` and this branch does nothing. `dragOver` keeps `DROP_MOVE`, 
and `drop` then uses `VfsFileTransfer.Mode.MOVE`, which `moveTo`s or copies and 
then deletes the source. That includes a drag from the operating-system file 
manager into the explorer: the local files are removed after the VFS copy. 
Ctrl+drag is a copy; Shift+drag is also a move, so there is no unmodified copy 
gesture. Windows happens to work because it applies the MOVE default only after 
 the listener, and this listener still sees `DROP_DEFAULT`.
   
   **Suggestion:** On `dragEnter` and `dragOver`, force `DROP_COPY` unless the 
user is actually holding the platform move modifier. Do not treat "MOVE is in 
the allowed operations" as a request to move. Add a UI test or a headless check 
that an unmodified drop uses `Mode.COPY`.



##########
ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java:
##########
@@ -1432,52 +2061,62 @@ private void pasteClipboard() {
         destinationUri,
         entries,
         mode,
-        BaseMessages.getString(PKG, operationKey, entries.get(0).getName()));
+        BaseMessages.getString(PKG, operationKey, entries.get(0).getName()),
+        clipboard);
   }
 
   private void copyIntoFolder(
       String destinationUri,
       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;
+          VfsFileTransfer.ConflictResolver resolver = conflictResolver();
           int done = 0;
           for (VfsFileTransfer.Entry entry : entries) {
-            if (Thread.currentThread().isInterrupted()) {
-              break;
+            if (operation.isCancelled()) {
+              throw new CancellationException();
             }
             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;
+            try {
+              VfsFileTransfer.transfer(
+                  source,
+                  destination,
+                  mode,
+                  resolver,
+                  operation::isCancelled,
+                  operation.progress());
+              done++;
+            } catch (VfsFileTransfer.RefusedException e) {
+              errors.add(transferMessage(e.getRefusal(), e.getName()));
             }
-            VfsFileTransfer.transfer(source, destination, mode, overwrite);
-            done++;
           }
-          if (mode == VfsFileTransfer.Mode.MOVE && done > 0) {
-            VfsFileClipboard.clear();
+          if (clipboard != null && mode == VfsFileTransfer.Mode.MOVE && done > 
0) {

Review Comment:
   **[bug]** A cut is cleared, or left unusable, after a paste that did not 
move every file. `done` is incremented whenever `transfer` returns, and `place` 
returns normally for Skip as well as for a successful replace. Skip or Skip all 
on a cut therefore clears the clipboard even though every source is still in 
place. A later `IOException` or `CancellationException` is not caught in this 
loop, so it skips the `clear` and also skips every entry after the failure. 
Files already moved are gone, but the clipboard still points at the old URIs. 
The next paste opens the missing file, throws (not a `RefusedException`), and 
aborts before the entries that were not moved yet. The user cannot finish the 
cut without selecting the survivors again, and the error does not say which 
names already moved.
   
   **Suggestion:** Count only entries that were actually moved. On Skip, leave 
that entry on the clipboard. On cancel or a hard failure, drop the entries 
already moved and keep the rest, or keep going and report each failure the same 
way `RefusedException` is reported. Add the multi-item "some refused / some 
skipped" paste test the issue asked for, including a cut.



##########
ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransfer.java:
##########
@@ -102,32 +165,129 @@ public static Refusal refusal(FileObject source, 
FileObject destinationFolder)
       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 temp = target.getParent().resolveFile(TEMP_PREFIX + 
UUID.randomUUID() + ".tmp");
+    // copyFile removes a partly written temp file itself.
+    copyFile(source, temp, cancelled, progress == null ? Progress.NONE : 
progress);
+    try {
+      target.delete();

Review Comment:
   **[suggestion]** `replaceFile` deletes the destination before 
`temp.moveTo(target)`. The temp copy is complete, so a stopped or failed copy 
still leaves the original, which matches the issue. If `moveTo` then fails, the 
name the user had is already gone and the new bytes exist only as a hidden 
`.hop-transfer-*.tmp` sibling. On object stores `moveTo` is itself a copy plus 
delete, so that second copy can fail after the original key was removed. The 
failure dialog does not mention the temp name, and hidden files are off by 
default, so the file looks deleted.
   
   **Suggestion:** Rename the original aside, move the temp onto the real name, 
then delete the backup. If the move fails, put the backup back and delete the 
temp. If a temp must be left behind, include its name in the operation error.



##########
ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java:
##########
@@ -302,10 +359,171 @@ public VfsFileExplorerLocation(Composite parent, 
VfsFileExplorer explorer) {
           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 unless a 
move is asked for:
+   * the source and target are often different stores.
+   */
+  private void installDragAndDrop() {
+    FileTransfer fileTransfer = FileTransfer.getInstance();
+    Transfer[] transfers = {fileTransfer};
+
+    DragSource treeSource = new DragSource(tree, DND.DROP_COPY | 
DND.DROP_MOVE);

Review Comment:
   **[bug]** A Hop Web user without `explorer.write` can still write by 
dragging out of this explorer. The new drag source offers `FileTransfer` with 
copy and move and does not check `canWrite()`. The in-explorer drop target does 
call `checkWrite()`, but the File Explorer perspective drop 
(`ExplorerPerspective` drop listener, `copyFrom` / `moveFile`) never checks 
`EXPLORER_WRITE`. That drop was already unsafe for drags inside the 
perspective; this change newly lets a read-only session drag a project file, a 
server file, or a named connection (S3, FTP, and so on) onto the perspective 
tree. Combined with issue 1, that drag is a move on Linux and macOS, so it 
deletes the source as well as writing the destination. Disabling the paste, 
cut, and delete buttons does not close this path.
   
   **Suggestion:** Refuse `dragStart` when `HopSecurity.allows(EXPLORER_WRITE)` 
is false, or make the perspective drop call the same 
`HopSecurityUi.check(Permission.EXPLORER_WRITE)` guard as 
`getSelectedFolderPath` before it copies or moves. A read-only user may still 
copy a path or download when `file.export` allows it; they must not be able to 
create or delete files through a drop.



##########
ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java:
##########
@@ -772,11 +1133,127 @@ public void uploadSelected() {
     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();
+    runChange(
+        BaseMessages.getString(PKG, "VfsFileExplorer.Operation.Downloading", 
name),
+        sourceUri,
+        false,
+        operation -> {
+          Path local = Files.createTempFile("hop-vfs-download-", ".tmp");

Review Comment:
   **[suggestion]** Hop Web download does not observe Stop and does not use the 
session temp directory. `Files.copy` runs to completion with no 
`operation.isCancelled()` check, then `asyncExec` still calls 
`BrowserFileTransferFacade.download` after the user has stopped the operation. 
The temp file is created with `Files.createTempFile` in the JVM temp dir, not 
under `HopWebUserFilePlugin.getSessionTempDirectory()`. `deleteLocalQuietly` 
claims the session folder will remove it, but that cleanup never sees this 
path. If the display is disposed before the `asyncExec` runs, or the delete 
fails, the server copy is leaked outside the session directory.
   
   **Suggestion:** Copy into the session temp directory, stop between chunks 
the same way `VfsFileTransfer.copyFile` does, and skip `download` when the 
operation is already cancelled. Delete the temp in a `finally` on the worker if 
the UI hand-off does not run.



##########
ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileExplorerLocation.java:
##########
@@ -302,10 +359,171 @@ public VfsFileExplorerLocation(Composite parent, 
VfsFileExplorer explorer) {
           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 unless a 
move is asked for:
+   * the source and target are often different stores.
+   */
+  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));
+
+    DropTarget treeTarget = new DropTarget(tree, DND.DROP_COPY | 
DND.DROP_MOVE);
+    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, DND.DROP_COPY | 
DND.DROP_MOVE);
+    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) {
+        List<String> uris = urisOf(selection.get());
+        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) {
+        if (event.detail == DND.DROP_DEFAULT) {
+          event.detail = (event.operations & DND.DROP_COPY) != 0 ? 
DND.DROP_COPY : DND.DROP_NONE;
+        }
+      }
+
+      @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 if (event.detail == DND.DROP_NONE || event.detail == 
DND.DROP_DEFAULT) {
+          event.detail = (event.operations & DND.DROP_COPY) != 0 ? 
DND.DROP_COPY : DND.DROP_MOVE;
+        }
+      }
+
+      @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;
+        }
+        List<String> uris = List.of(paths);
+        if (!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));

Review Comment:
   **[suggestion]** Internal drags are de-duplicated by `selectedFileEntries` / 
`selectedFolderEntries` before the paths are put on the transfer, but the drop 
rebuilds every entry with `folder` false and never calls `withoutNested`. A 
drop from the file manager or the File Explorer perspective can contain both a 
folder and a file or folder inside it. Those are copied as siblings, and a move 
can take the child out of the folder before the folder itself is moved. The 
false flag also means a later clipboard-style nested check cannot see the 
directory.
   
   **Suggestion:** Before `copyIntoFolder`, stat the paths (or carry the folder 
bit) and run `VfsFileTransfer.withoutNested`, so a directory and a path inside 
it are one transfer.



##########
ui/src/main/java/org/apache/hop/ui/hopgui/vfs/explorer/VfsFileTransfer.java:
##########
@@ -171,4 +331,67 @@ public static boolean isInside(FileObject folder, 
FileObject candidate)
     }
     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);
+  }
+
+  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();

Review Comment:
   **[suggestion]** Stop deletes the file that was mid-write (`copyFile`'s 
catch calls `deleteQuietly`), but a folder copy already called `createFolder` 
and may have finished earlier children. Those stay behind when 
`CancellationException` unwinds `copyTree`. A stopped move does not delete the 
source, so the user is left with a partial extra tree and no signal of which 
names finished. The new test only covers a single file.
   
   **Suggestion:** On cancel, delete a destination folder this call created 
(and the children it copied into it) when the copy did not finish. Leave an 
existing folder that was being merged alone, apart from the partial file 
already removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to