diff --git a/app/src/main/java/io/xpipe/app/browser/FileBrowserComp.java b/app/src/main/java/io/xpipe/app/browser/FileBrowserComp.java index 767d9ad8..c70c7bbe 100644 --- a/app/src/main/java/io/xpipe/app/browser/FileBrowserComp.java +++ b/app/src/main/java/io/xpipe/app/browser/FileBrowserComp.java @@ -38,7 +38,11 @@ public class FileBrowserComp extends SimpleComp { @Override protected Region createSimple() { var bookmarksList = new BookmarkList(model).createRegion(); - var splitPane = new SplitPane(bookmarksList, createTabs()); + var localDownloadStage = new LocalFileTransferComp(model.getLocalTransfersStage()).createRegion(); + var vertical = new VBox(bookmarksList, localDownloadStage); + vertical.setFillWidth(true); + + var splitPane = new SplitPane(vertical, createTabs()); splitPane .widthProperty() .addListener( diff --git a/app/src/main/java/io/xpipe/app/browser/FileBrowserModel.java b/app/src/main/java/io/xpipe/app/browser/FileBrowserModel.java index 504cc016..81abdbdd 100644 --- a/app/src/main/java/io/xpipe/app/browser/FileBrowserModel.java +++ b/app/src/main/java/io/xpipe/app/browser/FileBrowserModel.java @@ -40,6 +40,7 @@ public class FileBrowserModel { private final ObservableList openFileSystems = FXCollections.observableArrayList(); private final Property selected = new SimpleObjectProperty<>(); + private final LocalFileTransferStage localTransfersStage = new LocalFileTransferStage(); public void finishChooser() { if (getMode().equals(Mode.BROWSER)) { diff --git a/app/src/main/java/io/xpipe/app/browser/FileIcons.java b/app/src/main/java/io/xpipe/app/browser/FileIcons.java new file mode 100644 index 00000000..12bf7ca3 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/FileIcons.java @@ -0,0 +1,20 @@ +package io.xpipe.app.browser; + +import io.xpipe.app.fxcomps.impl.PrettyImageComp; +import io.xpipe.core.store.FileSystem; +import javafx.beans.property.SimpleStringProperty; + +public class FileIcons { + + public static PrettyImageComp createIcon(FileSystem.FileEntry entry) { + return new PrettyImageComp(new SimpleStringProperty(getIcon(entry)), 22, 22); + } + + public static String getIcon(FileSystem.FileEntry entry) { + if (!entry.isDirectory()) { + return "app:file_drag_icon.png"; + } else { + return "app:folder_closed.svg"; + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/FileListCompEntry.java b/app/src/main/java/io/xpipe/app/browser/FileListCompEntry.java index 34cf4dfa..58f0a688 100644 --- a/app/src/main/java/io/xpipe/app/browser/FileListCompEntry.java +++ b/app/src/main/java/io/xpipe/app/browser/FileListCompEntry.java @@ -1,10 +1,11 @@ package io.xpipe.app.browser; -import io.xpipe.app.core.AppResources; import io.xpipe.core.store.FileSystem; import javafx.geometry.Point2D; import javafx.scene.Node; -import javafx.scene.image.Image; +import javafx.scene.Scene; +import javafx.scene.SnapshotParameters; +import javafx.scene.image.WritableImage; import javafx.scene.input.*; import lombok.Getter; @@ -124,13 +125,15 @@ public class FileListCompEntry { return; } - var url = AppResources.getResourceURL(AppResources.XPIPE_MODULE, "img/file_drag_icon.png") - .orElseThrow(); - var image = new Image(url.toString(), 80, 80, true, false); var selected = model.getSelected(); Dragboard db = row.startDragAndDrop(TransferMode.COPY); db.setContent(FileBrowserClipboard.startDrag(model.getFileSystemModel().getCurrentDirectory(), selected)); - db.setDragView(image, 30, 60); + + var r = new SelectedFileListComp(selected).createRegion(); + new Scene(r); + WritableImage image = r.snapshot(new SnapshotParameters(), null); + db.setDragView(image, -20, 15); + event.setDragDetect(true); event.consume(); } diff --git a/app/src/main/java/io/xpipe/app/browser/FileSystemHelper.java b/app/src/main/java/io/xpipe/app/browser/FileSystemHelper.java index 1d6948c3..e31bb9aa 100644 --- a/app/src/main/java/io/xpipe/app/browser/FileSystemHelper.java +++ b/app/src/main/java/io/xpipe/app/browser/FileSystemHelper.java @@ -72,9 +72,15 @@ public class FileSystemHelper { return FileNames.toDirectory(normalized); } + private static FileSystem localFileSystem; + public static FileSystem.FileEntry getLocal(Path file) throws Exception { + if (localFileSystem == null) { + localFileSystem = new LocalStore().createFileSystem(); + } + return new FileSystem.FileEntry( - LocalStore.getFileSystem(), + localFileSystem, file.toString(), Files.getLastModifiedTime(file).toInstant(), Files.isDirectory(file), diff --git a/app/src/main/java/io/xpipe/app/browser/LocalFileTransferComp.java b/app/src/main/java/io/xpipe/app/browser/LocalFileTransferComp.java new file mode 100644 index 00000000..27839fec --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/LocalFileTransferComp.java @@ -0,0 +1,104 @@ +package io.xpipe.app.browser; + +import io.xpipe.app.comp.base.LoadingOverlayComp; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.fxcomps.SimpleComp; +import io.xpipe.app.fxcomps.impl.LabelComp; +import io.xpipe.app.fxcomps.impl.StackComp; +import io.xpipe.app.fxcomps.impl.VerticalComp; +import io.xpipe.app.fxcomps.util.BindingsHelper; +import io.xpipe.app.fxcomps.util.PlatformThread; +import javafx.beans.binding.Bindings; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.SnapshotParameters; +import javafx.scene.image.WritableImage; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.Dragboard; +import javafx.scene.input.TransferMode; +import javafx.scene.layout.Region; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.io.IOException; +import java.util.List; + +public class LocalFileTransferComp extends SimpleComp { + + private final LocalFileTransferStage stage; + + public LocalFileTransferComp(LocalFileTransferStage stage) { + this.stage = stage; + } + + @Override + protected Region createSimple() { + var background = new LabelComp(AppI18n.observable("download")) + .apply(struc -> struc.get().setGraphic(new FontIcon("mdi2d-download-outline"))) + .visible(BindingsHelper.persist(Bindings.isEmpty(stage.getItems()))); + var backgroundStack = + new StackComp(List.of(background)).grow(true, true).styleClass("download-background"); + var binding = BindingsHelper.mappedContentBinding(stage.getItems(), item -> item.getFileEntry()); + var list = new SelectedFileListComp(binding).apply(struc -> struc.get().setMinHeight(200)); + var dragNotice = new LabelComp(AppI18n.observable("dragFiles")) + .apply(struc -> struc.get().setGraphic(new FontIcon("mdi2e-export"))) + .hide(BindingsHelper.persist(Bindings.isEmpty(stage.getItems()))) + .grow(true, false) + .apply(struc -> struc.get().setPadding(new Insets(8))); + var loading = new LoadingOverlayComp( + new VerticalComp(List.of(list, dragNotice)), PlatformThread.sync(stage.getDownloading())); + var stack = new StackComp(List.of(backgroundStack, loading)).apply(struc -> { + struc.get().setOnDragOver(event -> { + // Accept drops from inside the app window + if (event.getGestureSource() != null) { + event.acceptTransferModes(TransferMode.ANY); + event.consume(); + } + }); + struc.get().setOnDragDropped(event -> { + if (event.getGestureSource() != null) { + var files = FileBrowserClipboard.retrieveDrag(event.getDragboard()) + .getEntries(); + stage.drop(files); + event.setDropCompleted(true); + event.consume(); + } + }); + struc.get().setOnDragDetected(event -> { + if (stage.getDownloading().get()) { + return; + } + + var files = stage.getItems().stream() + .map(item -> { + try { + return item.getLocalFile().toRealPath().toFile(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList(); + Dragboard db = struc.get().startDragAndDrop(TransferMode.MOVE); + var cc = new ClipboardContent(); + cc.putFiles(files); + db.setContent(cc); + + var r = new SelectedFileListComp(FXCollections.observableList(stage.getItems().stream() + .map(item -> item.getFileEntry()) + .toList())) + .createRegion(); + new Scene(r); + WritableImage image = r.snapshot(new SnapshotParameters(), null); + db.setDragView(image, -20, 15); + + event.setDragDetect(true); + event.consume(); + }); + struc.get().setOnDragDone(event -> { + stage.getItems().clear(); + event.consume(); + }); + }); + return stack.createRegion(); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/LocalFileTransferStage.java b/app/src/main/java/io/xpipe/app/browser/LocalFileTransferStage.java new file mode 100644 index 00000000..c6e33b34 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/LocalFileTransferStage.java @@ -0,0 +1,47 @@ +package io.xpipe.app.browser; + +import io.xpipe.app.util.BusyProperty; +import io.xpipe.app.util.ThreadHelper; +import io.xpipe.core.impl.FileNames; +import io.xpipe.core.store.FileSystem; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import lombok.Value; +import org.apache.commons.io.FileUtils; + +import java.nio.file.Path; +import java.util.List; + +@Value +public class LocalFileTransferStage { + + private static final Path TEMP = + FileUtils.getTempDirectory().toPath().resolve("xpipe").resolve("download"); + + @Value + public static class Item { + FileSystem.FileEntry fileEntry; + Path localFile; + BooleanProperty finishedDownload = new SimpleBooleanProperty(); + } + + ObservableList items = FXCollections.observableArrayList(); + BooleanProperty downloading = new SimpleBooleanProperty(); + + public void drop(List entries) { + entries.forEach(entry -> { + Path file = TEMP.resolve(FileNames.getFileName(entry.getPath())); + var item = new Item(entry, file); + items.add(item); + ThreadHelper.runFailableAsync(() -> { + FileUtils.forceMkdirParent(TEMP.toFile()); + try (var b = new BusyProperty(downloading)) { + FileSystemHelper.dropFilesInto(FileSystemHelper.getLocal(TEMP),List.of(entry), false); + } + item.finishedDownload.set(true); + }); + }); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/SelectedFileListComp.java b/app/src/main/java/io/xpipe/app/browser/SelectedFileListComp.java new file mode 100644 index 00000000..81f01290 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/SelectedFileListComp.java @@ -0,0 +1,28 @@ +package io.xpipe.app.browser; + +import io.xpipe.app.comp.base.ListBoxViewComp; +import io.xpipe.app.fxcomps.SimpleComp; +import io.xpipe.app.fxcomps.impl.LabelComp; +import io.xpipe.core.impl.FileNames; +import io.xpipe.core.store.FileSystem; +import javafx.collections.ObservableList; +import javafx.scene.layout.Region; +import lombok.EqualsAndHashCode; +import lombok.Value; + +@Value +@EqualsAndHashCode(callSuper = true) +public class SelectedFileListComp extends SimpleComp { + + ObservableList list; + + @Override + protected Region createSimple() { + var c = new ListBoxViewComp<>(list, list, entry -> { + var l = new LabelComp(FileNames.getFileName(entry.getPath())).apply(struc -> struc.get() + .setGraphic(FileIcons.createIcon(entry).createRegion())); + return l; + }).styleClass("selected-file-list"); + return c.createRegion(); + } +} diff --git a/app/src/main/java/io/xpipe/app/update/UpdateAvailableAlert.java b/app/src/main/java/io/xpipe/app/update/UpdateAvailableAlert.java index cfa77592..34d139f9 100644 --- a/app/src/main/java/io/xpipe/app/update/UpdateAvailableAlert.java +++ b/app/src/main/java/io/xpipe/app/update/UpdateAvailableAlert.java @@ -2,6 +2,7 @@ package io.xpipe.app.update; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppWindowHelper; +import io.xpipe.app.prefs.AppPrefs; import javafx.scene.control.Alert; public class UpdateAvailableAlert { @@ -11,6 +12,12 @@ public class UpdateAvailableAlert { return; } + // If we downloaded an update, and decided to no longer automatically update, don't remind us! + // You can still update manually in the about tab + if (!AppPrefs.get().automaticallyUpdate().get()) { + return; + } + if (AppUpdater.get().getDownloadedUpdate().getValue() != null && !AppUpdater.get().isDownloadedUpdateStillLatest()) { AppUpdater.get().getDownloadedUpdate().setValue(null); return; diff --git a/app/src/main/resources/io/xpipe/app/resources/lang/translations_en.properties b/app/src/main/resources/io/xpipe/app/resources/lang/translations_en.properties index 69de7e6d..2dbb8f2b 100644 --- a/app/src/main/resources/io/xpipe/app/resources/lang/translations_en.properties +++ b/app/src/main/resources/io/xpipe/app/resources/lang/translations_en.properties @@ -13,6 +13,8 @@ deleteAlertTitle=Confirm deletion deleteAlertHeader=Do you want to delete the ($COUNT$) selected elements? selectedElements=Selected elements: mustNotBeEmpty=$NAME$ must not be empty +download=Drop to download +dragFiles=Drag local files from here null=$VALUE$ must be not null hostFeatureUnsupported=Host does not support the feature $FEATURE$ missingStore=$NAME$ does not exist diff --git a/app/src/main/resources/io/xpipe/app/resources/style/browser.css b/app/src/main/resources/io/xpipe/app/resources/style/browser.css index cc6055af..8190bf9c 100644 --- a/app/src/main/resources/io/xpipe/app/resources/style/browser.css +++ b/app/src/main/resources/io/xpipe/app/resources/style/browser.css @@ -1,4 +1,17 @@ -/* SPDX-License-Identifier: MIT */ +.download-background { +-fx-border-color: -color-neutral-emphasis; +-fx-border-width: 1px 0 0 0; +-fx-padding: 1em; +-fx-background-color: -color-neutral-muted; +} + +.selected-file-list { + -fx-padding: 10px; +} + +.selected-file-list * { + -fx-spacing: 5px; +} .browser .bookmark-list { -fx-border-width: 0 0 1 1; @@ -26,8 +39,8 @@ } .browser .singular .tab-header-area { - visibility: hidden ; - } + visibility: hidden ; +} .browser .table-directory-view .table-view { -color-header-bg: -color-bg-default;