diff --git a/app/src/main/java/io/xpipe/app/browser/FileBrowserAlerts.java b/app/src/main/java/io/xpipe/app/browser/FileBrowserAlerts.java new file mode 100644 index 00000000..f28fd3fa --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/FileBrowserAlerts.java @@ -0,0 +1,51 @@ +package io.xpipe.app.browser; + +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.core.AppWindowHelper; +import io.xpipe.core.store.FileSystem; +import javafx.scene.control.Alert; + +import java.util.List; +import java.util.stream.Collectors; + +public class FileBrowserAlerts { + + public static boolean showMoveAlert(List source, FileSystem.FileEntry target) { + if (source.stream().noneMatch(entry -> entry.isDirectory())) { + return true; + } + + return AppWindowHelper.showBlockingAlert(alert -> { + alert.setTitle(AppI18n.get("moveAlertTitle")); + alert.setHeaderText(AppI18n.get("moveAlertHeader", source.size(), target.getPath())); + alert.getDialogPane().setContent(AppWindowHelper.alertContentText(getSelectedElementsString(source))); + alert.setAlertType(Alert.AlertType.CONFIRMATION); + }) + .map(b -> b.getButtonData().isDefaultButton()) + .orElse(false); + } + + public static boolean showDeleteAlert(List source) { + if (source.stream().noneMatch(entry -> entry.isDirectory())) { + return true; + } + + return AppWindowHelper.showBlockingAlert(alert -> { + alert.setTitle(AppI18n.get("deleteAlertTitle")); + alert.setHeaderText(AppI18n.get("deleteAlertHeader", source.size())); + alert.getDialogPane().setContent(AppWindowHelper.alertContentText(getSelectedElementsString(source))); + alert.setAlertType(Alert.AlertType.CONFIRMATION); + }) + .map(b -> b.getButtonData().isDefaultButton()) + .orElse(false); + } + + private static String getSelectedElementsString(List source) { + var namesHeader = AppI18n.get("selectedElements"); + var names = namesHeader + "\n" + source.stream().limit(10).map(entry -> "- " + entry.getPath()).collect(Collectors.joining("\n")); + if (source.size() > 10) { + names += "\n+ " + (source.size() - 10) + " ..."; + } + return names; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/FileContextMenu.java b/app/src/main/java/io/xpipe/app/browser/FileContextMenu.java index 54cb21e3..dba3e68d 100644 --- a/app/src/main/java/io/xpipe/app/browser/FileContextMenu.java +++ b/app/src/main/java/io/xpipe/app/browser/FileContextMenu.java @@ -147,8 +147,8 @@ final class FileContextMenu extends ContextMenu { var delete = new MenuItem("Delete"); delete.setOnAction(event -> { + model.deleteSelectionAsync(); event.consume(); - model.deleteAsync(entry.getPath()); }); var rename = new MenuItem("Rename"); 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 3ef41184..34cf4dfa 100644 --- a/app/src/main/java/io/xpipe/app/browser/FileListCompEntry.java +++ b/app/src/main/java/io/xpipe/app/browser/FileListCompEntry.java @@ -23,6 +23,7 @@ public class FileListCompEntry { private Point2D lastOver = new Point2D(-1, -1); private TimerTask activeTask; + private FileContextMenu currentContextMenu; public FileListCompEntry(Node row, FileSystem.FileEntry item, FileListModel model) { this.row = row; @@ -36,9 +37,15 @@ public class FileListCompEntry { return; } - var cm = new FileContextMenu(model.getFileSystemModel(), item, model.getEditing()); + if (currentContextMenu != null) { + currentContextMenu.hide(); + currentContextMenu = null; + } + if (t.getButton() == MouseButton.SECONDARY) { + var cm = new FileContextMenu(model.getFileSystemModel(), item, model.getEditing()); cm.show(row, t.getScreenX(), t.getScreenY()); + currentContextMenu = cm; } } 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 b02e495a..1d6948c3 100644 --- a/app/src/main/java/io/xpipe/app/browser/FileSystemHelper.java +++ b/app/src/main/java/io/xpipe/app/browser/FileSystemHelper.java @@ -23,16 +23,18 @@ public class FileSystemHelper { ConnectionFileSystem fileSystem = (ConnectionFileSystem) model.getFileSystem(); var current = !(model.getStore().getValue() instanceof LocalStore) ? fileSystem - .getShellControl() - .executeStringSimpleCommand(fileSystem - .getShellControl() - .getShellDialect() - .getPrintWorkingDirectoryCommand()) - : fileSystem.getShell().get().getOsType().getHomeDirectory(fileSystem.getShell().get()); - return FileSystemHelper.normalizeDirectoryPath(model, current); + .getShellControl() + .executeStringSimpleCommand( + fileSystem.getShellControl().getShellDialect().getPrintWorkingDirectoryCommand()) + : fileSystem + .getShell() + .get() + .getOsType() + .getHomeDirectory(fileSystem.getShell().get()); + return FileSystemHelper.resolveDirectoryPath(model, current); } - public static String normalizeDirectoryPath(OpenFileSystemModel model, String path) throws Exception { + public static String resolveDirectoryPath(OpenFileSystemModel model, String path) throws Exception { if (path == null) { return null; } @@ -65,6 +67,8 @@ public class FileSystemHelper { throw new IllegalArgumentException(String.format("Directory %s does not exist", normalized)); } + model.getFileSystem().directoryAccessible(normalized); + return FileNames.toDirectory(normalized); } @@ -96,6 +100,20 @@ public class FileSystemHelper { } } + public static void delete(List files) throws Exception { + if (files.size() == 0) { + return; + } + + for (var file : files) { + try { + file.getFileSystem().delete(file.getPath()); + } catch (Throwable t) { + ErrorEvent.fromThrowable(t).handle(); + } + } + } + public static void dropFilesInto( FileSystem.FileEntry target, List files, boolean explicitCopy) throws Exception { if (files.size() == 0) { diff --git a/app/src/main/java/io/xpipe/app/browser/OpenFileSystemComp.java b/app/src/main/java/io/xpipe/app/browser/OpenFileSystemComp.java index 074e2cc9..cbeaaaad 100644 --- a/app/src/main/java/io/xpipe/app/browser/OpenFileSystemComp.java +++ b/app/src/main/java/io/xpipe/app/browser/OpenFileSystemComp.java @@ -2,7 +2,9 @@ package io.xpipe.app.browser; import atlantafx.base.controls.Spacer; import io.xpipe.app.fxcomps.SimpleComp; +import io.xpipe.app.fxcomps.impl.PrettyImageComp; import io.xpipe.app.fxcomps.impl.TextFieldComp; +import io.xpipe.app.fxcomps.util.PlatformThread; import io.xpipe.core.impl.FileNames; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -52,11 +54,13 @@ public class OpenFileSystemComp extends SimpleComp { var terminalBtn = new Button(null, new FontIcon("mdi2c-code-greater-than")); terminalBtn.setOnAction(e -> model.openTerminalAsync(model.getCurrentPath().get())); + terminalBtn.disableProperty().bind(PlatformThread.sync(model.getNoDirectory())); var addBtn = new Button(null, new FontIcon("mdmz-plus")); addBtn.setOnAction(e -> { creatingProperty.set(true); }); + addBtn.disableProperty().bind(PlatformThread.sync(model.getNoDirectory())); var filter = new FileFilterComp(model.getFilter()).createRegion(); @@ -85,14 +89,14 @@ public class OpenFileSystemComp extends SimpleComp { pane.getChildren().add(root); var creation = createCreationWindow(creatingProperty); - var creationPain = new StackPane(creation); - creationPain.setAlignment(Pos.CENTER); - creationPain.setOnMouseClicked(event -> { + var creationPane = new StackPane(creation); + creationPane.setAlignment(Pos.CENTER); + creationPane.setOnMouseClicked(event -> { creatingProperty.set(false); }); - pane.getChildren().add(creationPain); - creationPain.visibleProperty().bind(creatingProperty); - creationPain.managedProperty().bind(creatingProperty); + pane.getChildren().add(creationPane); + creationPane.visibleProperty().bind(creatingProperty); + creationPane.managedProperty().bind(creatingProperty); return pane; } @@ -104,25 +108,32 @@ public class OpenFileSystemComp extends SimpleComp { creationName.setText(""); } }); - var createFileButton = new Button("Create file"); + var createFileButton = new Button("File", new PrettyImageComp(new SimpleStringProperty("file_drag_icon.png"), 20, 20).createRegion()); createFileButton.setOnAction(event -> { - model.createFileAsync(FileNames.join(model.getCurrentPath().get(), creationName.getText())); + model.createFileAsync(creationName.getText()); creating.set(false); }); - var createDirectoryButton = new Button("Create directory"); + var createDirectoryButton = new Button("Directory", new PrettyImageComp(new SimpleStringProperty("folder_closed.svg"), 20, 20).createRegion()); createDirectoryButton.setOnAction(event -> { - model.createDirectoryAsync(FileNames.join(model.getCurrentPath().get(), creationName.getText())); + model.createDirectoryAsync(creationName.getText()); creating.set(false); }); var buttonBar = new ButtonBar(); buttonBar.getButtons().addAll(createFileButton, createDirectoryButton); var creationContent = new VBox(creationName, buttonBar); creationContent.setSpacing(15); - var creation = new TitledPane("New", creationContent); + var creation = new TitledPane("New ...", creationContent); creation.setMaxWidth(400); creation.setCollapsible(false); creationContent.setPadding(new Insets(15)); creation.getStyleClass().add("elevated-3"); + + creating.addListener((observable, oldValue, newValue) -> { + if (newValue) { + creationName.requestFocus(); + } + }); + return creation; } } diff --git a/app/src/main/java/io/xpipe/app/browser/OpenFileSystemModel.java b/app/src/main/java/io/xpipe/app/browser/OpenFileSystemModel.java index 57165a92..3c0b6fa3 100644 --- a/app/src/main/java/io/xpipe/app/browser/OpenFileSystemModel.java +++ b/app/src/main/java/io/xpipe/app/browser/OpenFileSystemModel.java @@ -35,6 +35,7 @@ final class OpenFileSystemModel { private final FileBrowserNavigationHistory history = new FileBrowserNavigationHistory(); private final BooleanProperty busy = new SimpleBooleanProperty(); private final FileBrowserModel browserModel; + private final BooleanProperty noDirectory = new SimpleBooleanProperty(); public OpenFileSystemModel(FileBrowserModel browserModel) { this.browserModel = browserModel; @@ -77,7 +78,7 @@ final class OpenFileSystemModel { public Optional cd(String path) { String newPath = null; try { - newPath = FileSystemHelper.normalizeDirectoryPath(this, path); + newPath = FileSystemHelper.resolveDirectoryPath(this, path); } catch (Exception ex) { ErrorEvent.fromThrowable(ex).handle(); return Optional.of(currentPath.get()); @@ -116,10 +117,12 @@ final class OpenFileSystemModel { List newList; if (dir != null) { newList = getFileSystem().listFiles(dir).collect(Collectors.toCollection(ArrayList::new)); + noDirectory.set(false); } else { newList = getFileSystem().listRoots().stream() .map(s -> new FileSystem.FileEntry(getFileSystem(), s, Instant.now(), true, false, false, 0)) .collect(Collectors.toCollection(ArrayList::new)); + noDirectory.set(true); } fileList.setAll(newList); return true; @@ -151,14 +154,25 @@ final class OpenFileSystemModel { return; } + var same = files.get(0).getFileSystem().equals(target.getFileSystem()); + if (same) { + if (!FileBrowserAlerts.showMoveAlert(files, target)) { + return; + } + } + FileSystemHelper.dropFilesInto(target, files, explicitCopy); refreshInternal(); }); }); } - public void createDirectoryAsync(String path) { - if (path.isBlank()) { + public void createDirectoryAsync(String name) { + if (name.isBlank()) { + return; + } + + if (getCurrentDirectory() == null) { return; } @@ -168,14 +182,23 @@ final class OpenFileSystemModel { return; } - fileSystem.mkdirs(path); + var abs = FileNames.join(getCurrentDirectory().getPath(), name); + if (fileSystem.directoryExists(abs)) { + throw new IllegalStateException(String.format("Directory %s already exists", abs)); + } + + fileSystem.mkdirs(abs); refreshInternal(); }); }); } - public void createFileAsync(String path) { - if (path.isBlank()) { + public void createFileAsync(String name) { + if (name.isBlank()) { + return; + } + + if (getCurrentDirectory() == null) { return; } @@ -185,20 +208,25 @@ final class OpenFileSystemModel { return; } - fileSystem.touch(path); + var abs = FileNames.join(getCurrentDirectory().getPath(), name); + fileSystem.touch(abs); refreshInternal(); }); }); } - public void deleteAsync(String path) { + public void deleteSelectionAsync() { ThreadHelper.runFailableAsync(() -> { BusyProperty.execute(busy, () -> { if (fileSystem == null) { return; } - fileSystem.delete(path); + if (!FileBrowserAlerts.showDeleteAlert(fileList.getSelected())) { + return; + } + + FileSystemHelper.delete(fileList.getSelected()); refreshInternal(); }); }); @@ -249,7 +277,7 @@ final class OpenFileSystemModel { var command = s.create() .initWith(List.of(connection.getShellDialect().getCdCommand(directory))) .prepareTerminalOpen(); - TerminalHelper.open("", command); + TerminalHelper.open(directory, command); } }); }); diff --git a/app/src/main/java/io/xpipe/app/comp/storage/store/StoreCreationBarComp.java b/app/src/main/java/io/xpipe/app/comp/storage/store/StoreCreationBarComp.java index 6ec8c7c3..0fe3836a 100644 --- a/app/src/main/java/io/xpipe/app/comp/storage/store/StoreCreationBarComp.java +++ b/app/src/main/java/io/xpipe/app/comp/storage/store/StoreCreationBarComp.java @@ -48,7 +48,7 @@ public class StoreCreationBarComp extends SimpleComp { .shortcut(new KeyCodeCombination(KeyCode.D, KeyCombination.SHORTCUT_DOWN)) .apply(new FancyTooltipAugment<>("addDatabase")); - var box = new VerticalComp(List.of(newShellStore, newDbStore, newStreamStore, newOtherStore)); + var box = new VerticalComp(List.of(newShellStore, newDbStore, newStreamStore)); box.apply(s -> AppFont.medium(s.get())); var bar = box.createRegion(); bar.getStyleClass().add("bar"); diff --git a/app/src/main/java/io/xpipe/app/core/AppWindowHelper.java b/app/src/main/java/io/xpipe/app/core/AppWindowHelper.java index 6273a01d..2e09fff0 100644 --- a/app/src/main/java/io/xpipe/app/core/AppWindowHelper.java +++ b/app/src/main/java/io/xpipe/app/core/AppWindowHelper.java @@ -7,6 +7,7 @@ import io.xpipe.app.util.ThreadHelper; import javafx.application.ConditionalFeature; import javafx.application.Platform; import javafx.beans.value.ObservableValue; +import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.SceneAntialiasing; @@ -35,7 +36,9 @@ public class AppWindowHelper { var text = new Text(s); text.setWrappingWidth(450); AppFont.medium(text); - return new StackPane(text); + var sp = new StackPane(text); + sp.setPadding(new Insets(5)); + return sp; } public static Stage sideWindow( diff --git a/app/src/main/java/io/xpipe/app/issue/ExceptionConverter.java b/app/src/main/java/io/xpipe/app/issue/ExceptionConverter.java index 17601ae6..377c13d2 100644 --- a/app/src/main/java/io/xpipe/app/issue/ExceptionConverter.java +++ b/app/src/main/java/io/xpipe/app/issue/ExceptionConverter.java @@ -1,6 +1,7 @@ package io.xpipe.app.issue; import io.xpipe.app.core.AppI18n; +import io.xpipe.core.process.ProcessOutputException; import java.io.FileNotFoundException; @@ -14,6 +15,7 @@ public class ExceptionConverter { } return switch (ex) { + case ProcessOutputException e -> e.getOutput(); case StackOverflowError e -> AppI18n.get("app.stackOverflow"); case OutOfMemoryError e -> AppI18n.get("app.outOfMemory"); case FileNotFoundException e -> AppI18n.get("app.fileNotFound", msg); diff --git a/app/src/main/java/io/xpipe/app/prefs/ExternalTerminalType.java b/app/src/main/java/io/xpipe/app/prefs/ExternalTerminalType.java index 43fb4755..3f4e0d81 100644 --- a/app/src/main/java/io/xpipe/app/prefs/ExternalTerminalType.java +++ b/app/src/main/java/io/xpipe/app/prefs/ExternalTerminalType.java @@ -4,6 +4,7 @@ import io.xpipe.app.ext.PrefsChoiceValue; import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.util.ApplicationHelper; import io.xpipe.app.util.MacOsPermissions; +import io.xpipe.core.impl.FileNames; import io.xpipe.core.impl.LocalStore; import io.xpipe.core.process.OsType; import io.xpipe.core.process.ShellControl; @@ -45,7 +46,11 @@ public interface ExternalTerminalType extends PrefsChoiceValue { @Override protected String toCommand(String name, String command) { - return "-w 1 nt --title \"" + name + "\" " + command; + // A weird behavior in Windows Terminal causes the trailing + // backslash of a filepath to escape the closing quote in the title argument + // So just remove that slash + var fixedName = FileNames.removeTrailingSlash(name); + return "-w 1 nt --title \"" + fixedName + "\" " + command; } @Override diff --git a/app/src/main/java/io/xpipe/app/util/TerminalHelper.java b/app/src/main/java/io/xpipe/app/util/TerminalHelper.java index 9d1ce4cd..a614d035 100644 --- a/app/src/main/java/io/xpipe/app/util/TerminalHelper.java +++ b/app/src/main/java/io/xpipe/app/util/TerminalHelper.java @@ -11,7 +11,7 @@ public class TerminalHelper { } public static void open(String title, String command) throws Exception { - if (command.contains("\n") || !command.strip().equals(command)) { + if (command.contains("\n") || command.contains(" ")) { command = ScriptHelper.createLocalExecScript(command); } 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 b9a97f9d..ca457380 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 @@ -7,6 +7,11 @@ common=Common other=Other askpassAlertTitle=Askpass nullPointer=Null Pointer +moveAlertTitle=Confirm move +moveAlertHeader=Do you want to move the ($COUNT$) selected elements into $TARGET$? +deleteAlertTitle=Confirm deletion +deleteAlertHeader=Do you want to delete the ($COUNT$) selected elements? +selectedElements=Selected elements: mustNotBeEmpty=$NAME$ must not be empty null=$VALUE$ must be not null hostFeatureUnsupported=Host does not support the feature $FEATURE$ diff --git a/core/src/main/java/io/xpipe/core/impl/FileStore.java b/core/src/main/java/io/xpipe/core/impl/FileStore.java index 774e160b..a58cb62a 100644 --- a/core/src/main/java/io/xpipe/core/impl/FileStore.java +++ b/core/src/main/java/io/xpipe/core/impl/FileStore.java @@ -10,7 +10,6 @@ import lombok.Getter; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; -import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Path; @@ -77,10 +76,7 @@ public class FileStore extends JacksonizedValue implements FilenameStore, Stream @Override public OutputStream openOutput() throws Exception { - if (!fileSystem.createFileSystem().open().mkdirs(getParent())) { - throw new IOException("Unable to create directory: " + getParent()); - } - + fileSystem.createFileSystem().open().mkdirs(getParent()); return fileSystem.createFileSystem().open().openOutput(path); } diff --git a/core/src/main/java/io/xpipe/core/store/ConnectionFileSystem.java b/core/src/main/java/io/xpipe/core/store/ConnectionFileSystem.java index 2c7be8d8..c51d76f6 100644 --- a/core/src/main/java/io/xpipe/core/store/ConnectionFileSystem.java +++ b/core/src/main/java/io/xpipe/core/store/ConnectionFileSystem.java @@ -35,6 +35,11 @@ public class ConnectionFileSystem implements FileSystem { return shellControl.getShellDialect().directoryExists(shellControl, file).executeAndCheck(); } + @Override + public void directoryAccessible(String file) throws Exception { + shellControl.executeSimpleCommand(shellControl.getShellDialect().getCdCommand(file)); + } + @Override public Stream listFiles(String file) throws Exception { return shellControl.getShellDialect().listFiles(this, shellControl, file); @@ -107,11 +112,11 @@ public class ConnectionFileSystem implements FileSystem { } @Override - public boolean mkdirs(String file) throws Exception { + public void mkdirs(String file) throws Exception { try (var pc = shellControl.command(proc -> proc.getShellDialect() .getMkdirsCommand(file)).complex() .start()) { - return pc.discardAndCheckExit(); + pc.discardOrThrow(); } } diff --git a/core/src/main/java/io/xpipe/core/store/FileSystem.java b/core/src/main/java/io/xpipe/core/store/FileSystem.java index b3b569a1..d17d81c4 100644 --- a/core/src/main/java/io/xpipe/core/store/FileSystem.java +++ b/core/src/main/java/io/xpipe/core/store/FileSystem.java @@ -59,12 +59,14 @@ public interface FileSystem extends Closeable, AutoCloseable { void move(String file, String newFile) throws Exception; - boolean mkdirs(String file) throws Exception; + void mkdirs(String file) throws Exception; void touch(String file) throws Exception; boolean directoryExists(String file) throws Exception; + void directoryAccessible(String file) throws Exception; + Stream listFiles(String file) throws Exception; default Stream listFilesRecursively(String file) throws Exception { diff --git a/version b/version index 0d240c6b..d2d81b78 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.5.17 \ No newline at end of file +0.5.18 \ No newline at end of file