From 446834b4f9a268f41f5df994b899f99e297cc916 Mon Sep 17 00:00:00 2001 From: Dmitry Bushev Date: Sat, 27 Jul 2024 16:37:43 +0300 Subject: [PATCH] Soft delete projects by moving them to trash (#10440) close #10357 Changelog: - add: `LinuxTrashBin` implementing Freedesktop.org trash specification - add: `WindowsTrashBin` calling native platform APIs for Windows - add: `MacTrashBin` calling native platform APIs for MacOS - update: `project/delete` method moves projects to trash and falls back to directory deletion --- build.sbt | 7 +- .../DirectoriesFactory.java | 28 --- .../desktopenvironment/LinuxTrashBin.java | 220 ++++++++++++++++++ .../enso/desktopenvironment/MacTrashBin.java | 84 +++++++ .../org/enso/desktopenvironment/Platform.java | 83 +++++-- .../enso/desktopenvironment/RandomUtils.java | 51 ++++ .../org/enso/desktopenvironment/TrashBin.java | 20 ++ .../desktopenvironment/WindowsTrashBin.java | 96 ++++++++ .../desktopenvironment/DirectoriesTest.java | 18 +- .../enso/desktopenvironment/PlatformTest.java | 12 +- .../desktopenvironment/RandomUtilsTest.java | 45 ++++ .../enso/desktopenvironment/TrashBinTest.java | 87 +++++++ .../migration/ProjectsMigration.java | 9 +- .../enso/projectmanager/boot/MainModule.scala | 11 +- .../boot/command/ProjectListCommand.scala | 16 +- .../FileSystemCreateDirectoryCommand.scala | 17 +- .../filesystem/FileSystemDeleteCommand.scala | 17 +- .../filesystem/FileSystemExistsCommand.scala | 17 +- .../filesystem/FileSystemListCommand.scala | 17 +- .../FileSystemMoveDirectoryCommand.scala | 17 +- .../FileSystemWritePathCommand.scala | 17 +- .../projectmanager/boot/configuration.scala | 4 +- .../infrastructure/desktop/DesktopTrash.scala | 20 ++ .../infrastructure/desktop/TrashCan.scala | 16 ++ .../repository/ProjectFileRepository.scala | 18 +- .../ProjectFileRepositoryFactory.scala | 7 +- .../repository/ProjectRepository.scala | 7 + .../service/ProjectService.scala | 28 ++- .../migration/ProjectsMigrationTest.java | 2 +- .../enso/projectmanager/BaseServerSpec.scala | 11 +- .../filesystem/FileSystemServiceSpec.scala | 3 +- .../enso/projectmanager/test/Shredder.scala | 19 ++ 32 files changed, 906 insertions(+), 118 deletions(-) delete mode 100644 lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java create mode 100644 lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxTrashBin.java create mode 100644 lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacTrashBin.java create mode 100644 lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/RandomUtils.java create mode 100644 lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/TrashBin.java create mode 100644 lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsTrashBin.java create mode 100644 lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/RandomUtilsTest.java create mode 100644 lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/TrashBinTest.java create mode 100644 lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/DesktopTrash.scala create mode 100644 lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/TrashCan.scala create mode 100644 lib/scala/project-manager/src/test/scala/org/enso/projectmanager/test/Shredder.scala diff --git a/build.sbt b/build.sbt index a2563538cc0..d9f11cdbc2a 100644 --- a/build.sbt +++ b/build.sbt @@ -2870,8 +2870,11 @@ lazy val `desktop-environment` = .settings( frgaalJavaCompilerSetting, libraryDependencies ++= Seq( - "junit" % "junit" % junitVersion % Test, - "com.github.sbt" % "junit-interface" % junitIfVersion % Test + "org.graalvm.sdk" % "graal-sdk" % graalMavenPackagesVersion % "provided", + "commons-io" % "commons-io" % commonsIoVersion, + "org.slf4j" % "slf4j-api" % slf4jVersion, + "junit" % "junit" % junitVersion % Test, + "com.github.sbt" % "junit-interface" % junitIfVersion % Test ) ) diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java deleted file mode 100644 index e6f37e9bece..00000000000 --- a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.enso.desktopenvironment; - -final class DirectoriesFactory { - - private static final Directories INSTANCE = initDirectories(); - - private static Directories initDirectories() { - if (Platform.isLinux()) { - return new LinuxDirectories(); - } - - if (Platform.isMacOs()) { - return new MacOsDirectories(); - } - - if (Platform.isWindows()) { - return new WindowsDirectories(); - } - - throw new UnsupportedOperationException("Unsupported OS '" + Platform.getOsName() + "'"); - } - - private DirectoriesFactory() {} - - public static Directories getInstance() { - return INSTANCE; - } -} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxTrashBin.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxTrashBin.java new file mode 100644 index 00000000000..cbd17f7b1f9 --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxTrashBin.java @@ -0,0 +1,220 @@ +package org.enso.desktopenvironment; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import org.apache.commons.io.FileUtils; + +/** + * The Linux trash implementing the FreeDesktop.org Trash + * specification. + * + *

A trash directory contains two subdirectories, named info and files. The files directory + * contains the trashed files, and the info directory contains the corresponding trashinfo metadata + * for each trashed entry in the files directory. + */ +final class LinuxTrashBin implements TrashBin { + + private static final String XDG_DATA_HOME = "XDG_DATA_HOME"; + private static final String PATH_TRASH = "Trash"; + private static final String PATH_FILES = "files"; + private static final String PATH_INFO = "info"; + + private final LinuxDirectories directories = new LinuxDirectories(); + + @Override + public boolean isSupported() { + var trashDir = detectTrashDirectory(); + return Files.isDirectory(trashDir.resolve(PATH_FILES)) + && Files.isDirectory(trashDir.resolve(PATH_INFO)); + } + + @Override + public boolean moveToTrash(Path path) { + var trashDir = detectTrashDirectory(); + + if (Files.exists(path) && isSupported()) { + try { + var trashInfo = TrashInfo.create(trashDir.resolve(PATH_INFO), path); + + try { + Files.move( + path, + trashDir.resolve(PATH_FILES).resolve(trashInfo.fileName), + StandardCopyOption.ATOMIC_MOVE); + return true; + } catch (IOException e) { + boolean isSuccessful; + if (Files.isDirectory(path)) { + isSuccessful = + moveDirectoryToDirectory(path, trashDir.resolve(PATH_FILES), trashInfo.fileName); + } else { + isSuccessful = + moveFileToDirectory(path, trashDir.resolve(PATH_FILES), trashInfo.fileName); + } + + if (!isSuccessful) { + FileUtils.deleteQuietly(trashInfo.path.toFile()); + } + + return isSuccessful; + } + } catch (IOException e) { + return false; + } + + } else { + return false; + } + } + + private static boolean moveFileToDirectory(Path from, Path to, String fileName) { + var source = from.toFile(); + var destination = to.resolve(fileName).toFile(); + + try { + FileUtils.copyFile(source, destination); + FileUtils.delete(source); + + return true; + } catch (IOException e) { + FileUtils.deleteQuietly(destination); + return false; + } + } + + private static boolean moveDirectoryToDirectory(Path from, Path to, String fileName) { + var source = from.toFile(); + var destination = to.resolve(fileName).toFile(); + try { + FileUtils.copyDirectory(source, destination); + FileUtils.deleteDirectory(source); + + return true; + } catch (IOException e) { + FileUtils.deleteQuietly(destination); + return false; + } + } + + /** + * Detect the path to a home trash directory of the current user. + * + *

The home trash directory should be automatically created for any new user. If the directory + * does not exist, it will be created. + * + * @return the path to the trash directory. + */ + private Path detectTrashDirectory() { + var xdgDataHomeOverride = System.getenv(XDG_DATA_HOME); + var xdgDataHome = + xdgDataHomeOverride == null + ? directories.getUserHome().resolve(".local").resolve("share") + : Path.of(xdgDataHomeOverride); + + var trashDir = xdgDataHome.resolve(PATH_TRASH); + + try { + Files.createDirectories(trashDir.resolve(PATH_FILES)); + } catch (IOException ignored) { + } + + try { + Files.createDirectories(trashDir.resolve(PATH_INFO)); + } catch (IOException ignored) { + } + + return trashDir; + } + + /** + * The trashinfo metadata file. + * + * @param path the path to this trashinfo file. + * @param fileName the file name that should be used to store the trashed file. + */ + private record TrashInfo(Path path, String fileName) { + + private static final int SUFFIX_SIZE = 6; + private static final int MAX_ATTEMPTS = Byte.MAX_VALUE; + private static final String TRASHINFO_EXTENSION = ".trashinfo"; + + /** + * Create the .trashinfo file containing the deleted file metadata. + * + * @param trashInfo the path to the trashinfo directory. + * @param toDelete the path to the file that should be deleted. + * @return the trashinfo metadata file. + * @throws IOException if the file creation was unsuccessful. + */ + public static TrashInfo create(Path trashInfo, Path toDelete) throws IOException { + var builder = new StringBuilder(); + builder.append("[Trash Info]"); + builder.append(System.lineSeparator()); + builder.append("Path="); + builder.append(toDelete.toAbsolutePath()); + builder.append(System.lineSeparator()); + builder.append("DeletionDate="); + builder.append( + LocalDateTime.now() + .truncatedTo(ChronoUnit.SECONDS) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + builder.append(System.lineSeparator()); + + return createTrashInfo(trashInfo, toDelete, builder, "", 0); + } + + /** + * Create the .trashinfo file containing the deleted file metadata. + * + *

In case of a name clash, when the trash already contains the file with the same name, the + * trashinfo file is created with a random suffix to resolve the conflict. + * + *

The file creation is atomic to so that if two processes try trash files with the same + * filename this will result in two different trash files. + * + * @param trashInfo the path to the trashinfo directory. + * @param toDelete the path to the file that should be deleted. + * @param contents the trashinfo file contents. + * @param suffix the trashinfo suffix to resolve the file name conflicts. + * @param attempts the number of attempts to resolve the name clash. + * @return the trashinfo metadata file. + * @throws IOException if the file creation was unsuccessful. + */ + private static TrashInfo createTrashInfo( + Path trashInfo, Path toDelete, CharSequence contents, String suffix, int attempts) + throws IOException { + if (attempts > MAX_ATTEMPTS) { + throw new IOException("Failed to create trashinfo file. Max attempts reached."); + } + + try { + var fileName = toDelete.getFileName().toString() + suffix; + var path = + Files.writeString( + trashInfo.resolve(fileName + TRASHINFO_EXTENSION), + contents, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE); + + return new TrashInfo(path, fileName); + } catch (FileAlreadyExistsException e) { + return createTrashInfo( + trashInfo, + toDelete, + contents, + RandomUtils.alphanumericString(SUFFIX_SIZE), + attempts + 1); + } + } + } +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacTrashBin.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacTrashBin.java new file mode 100644 index 00000000000..4d34d1abe37 --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacTrashBin.java @@ -0,0 +1,84 @@ +package org.enso.desktopenvironment; + +import java.nio.file.Path; +import java.util.List; +import org.graalvm.nativeimage.UnmanagedMemory; +import org.graalvm.nativeimage.c.CContext; +import org.graalvm.nativeimage.c.function.CFunction; +import org.graalvm.nativeimage.c.type.CCharPointer; +import org.graalvm.nativeimage.c.type.CTypeConversion; +import org.graalvm.word.Pointer; +import org.graalvm.word.WordFactory; +import org.slf4j.LoggerFactory; + +@CContext(MacTrashBin.CoreServices.class) +final class MacTrashBin implements TrashBin { + + @CFunction + static native int FSPathMakeRefWithOptions( + CCharPointer path, int flags, Pointer buffer, Pointer any); + + @CFunction + static native int FSMoveObjectToTrashSync(Pointer source, Pointer target, int flags); + + @Override + public boolean isSupported() { + return true; + } + + @Override + public boolean moveToTrash(Path path) { + if (Platform.getOperatingSystem().isMacOs()) { + try { + return moveToTrashImpl(path); + } catch (NullPointerException | LinkageError err) { + if (!Boolean.getBoolean("com.oracle.graalvm.isaot")) { + var logger = LoggerFactory.getLogger(MacTrashBin.class); + logger.warn("Moving to MacOS's Trash Bin is not supported in non-AOT mode."); + return false; + } else { + throw err; + } + } + } else { + return false; + } + } + + private boolean moveToTrashImpl(Path path) { + Pointer source = UnmanagedMemory.malloc(80); + Pointer target = UnmanagedMemory.malloc(80); + + var kFSPathMakeRefDoNotFollowLeafSymlink = 0x01; + var kFSFileOperationDefaultOptions = 0x00; + try (var cPath = CTypeConversion.toCString(path.toString())) { + var r1 = + FSPathMakeRefWithOptions( + cPath.get(), kFSPathMakeRefDoNotFollowLeafSymlink, source, WordFactory.nullPointer()); + var r2 = FSMoveObjectToTrashSync(source, target, kFSFileOperationDefaultOptions); + return r1 == 0 && r2 == 0; + } catch (Throwable error) { + return false; + } finally { + UnmanagedMemory.free(source); + UnmanagedMemory.free(target); + } + } + + public static final class CoreServices implements CContext.Directives { + @Override + public boolean isInConfiguration() { + return Platform.getOperatingSystem().isMacOs(); + } + + @Override + public List getHeaderFiles() { + return List.of(""); + } + + @Override + public List getLibraries() { + return List.of("-framework CoreServices"); + } + } +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java index 2bc20350dc8..43045f79a7f 100644 --- a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java @@ -1,31 +1,86 @@ package org.enso.desktopenvironment; -public final class Platform { +/** Identification of the desktop platform. */ +public enum Platform { + LINUX, + MACOS, + WINDOWS; private static final String OS_NAME = "os.name"; - private static final String LINUX = "linux"; - private static final String MAC = "mac"; - private static final String WINDOWS = "windows"; + private static final String OS_LINUX = "linux"; + private static final String OS_MAC = "mac"; + private static final String OS_WINDOWS = "windows"; + + private static final Platform OPERATING_SYSTEM = detectOperatingSystem(); + + /** + * @GuardedBy("this") + */ + private Directories directories; + + /** + * @GuardedBy("this") + */ + private TrashBin trashBin; private Platform() {} - public static String getOsName() { - return System.getProperty(OS_NAME); + private static Platform detectOperatingSystem() { + var osName = System.getProperty(OS_NAME); + var lowerOsName = osName.toLowerCase(); + + if (lowerOsName.contains(OS_LINUX)) { + return LINUX; + } + + if (lowerOsName.contains(OS_MAC)) { + return MACOS; + } + + if (lowerOsName.contains(OS_WINDOWS)) { + return WINDOWS; + } + + throw new IllegalStateException("Unknown Operrating System: '" + osName + "'"); } - public static boolean isLinux() { - return getOsName().toLowerCase().contains(LINUX); + public static Platform getOperatingSystem() { + return OPERATING_SYSTEM; } - public static boolean isMacOs() { - return getOsName().toLowerCase().contains(MAC); + public boolean isLinux() { + return this == LINUX; } - public static boolean isWindows() { - return getOsName().toLowerCase().contains(WINDOWS); + public boolean isMacOs() { + return this == MACOS; } - public static Directories getDirectories() { - return DirectoriesFactory.getInstance(); + public boolean isWindows() { + return this == WINDOWS; + } + + public synchronized Directories getDirectories() { + if (directories == null) { + directories = + switch (Platform.getOperatingSystem()) { + case LINUX -> new LinuxDirectories(); + case MACOS -> new MacOsDirectories(); + case WINDOWS -> new WindowsDirectories(); + }; + } + return directories; + } + + public synchronized TrashBin getTrashBin() { + if (trashBin == null) { + trashBin = + switch (this) { + case LINUX -> new LinuxTrashBin(); + case MACOS -> new MacTrashBin(); + case WINDOWS -> new WindowsTrashBin(); + }; + } + return trashBin; } } diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/RandomUtils.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/RandomUtils.java new file mode 100644 index 00000000000..8636eb002db --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/RandomUtils.java @@ -0,0 +1,51 @@ +package org.enso.desktopenvironment; + +import java.util.Random; + +final class RandomUtils { + + private static final char[] ALPHANUMERIC = + new char[] { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', + 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + 'A', 'B', 'C', 'D', 'E', 'F', 'J', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', + 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z' + }; + + private RandomUtils() {} + + /** Delay the {@link Random} instance initialization when building the native image. */ + private static final class LazyRandom { + + private static Random instance = null; + + private LazyRandom() {} + + public static Random getInstance() { + if (instance == null) { + instance = new Random(); + } + return instance; + } + } + + /** + * Get random alphanumeric ASCII string. + * + * @param size the size of the result string. + * @return the random string. + */ + public static synchronized String alphanumericString(int size) { + if (size < 0) { + throw new IllegalArgumentException("String size should be positive."); + } + + var random = LazyRandom.getInstance(); + var builder = new StringBuilder(size); + while (builder.length() < size) { + builder.append(ALPHANUMERIC[random.nextInt(ALPHANUMERIC.length)]); + } + + return builder.toString(); + } +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/TrashBin.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/TrashBin.java new file mode 100644 index 00000000000..2ddac00149c --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/TrashBin.java @@ -0,0 +1,20 @@ +package org.enso.desktopenvironment; + +import java.nio.file.Path; + +/** Operations with system trash */ +public sealed interface TrashBin permits LinuxTrashBin, WindowsTrashBin, MacTrashBin { + + /** + * @return {@code true} if the trash functionality is supported on this platform. + */ + boolean isSupported(); + + /** + * Move the specified path to the trash bin. + * + * @param path the file path. + * @return {@code true} if the operation was successful. + */ + boolean moveToTrash(Path path); +} diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsTrashBin.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsTrashBin.java new file mode 100644 index 00000000000..e255b35e778 --- /dev/null +++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsTrashBin.java @@ -0,0 +1,96 @@ +package org.enso.desktopenvironment; + +import java.nio.file.Path; +import java.util.List; +import org.graalvm.nativeimage.StackValue; +import org.graalvm.nativeimage.c.CContext; +import org.graalvm.nativeimage.c.constant.CConstant; +import org.graalvm.nativeimage.c.function.CFunction; +import org.graalvm.nativeimage.c.struct.CField; +import org.graalvm.nativeimage.c.struct.CStruct; +import org.graalvm.nativeimage.c.type.CCharPointer; +import org.graalvm.nativeimage.c.type.CTypeConversion; +import org.graalvm.word.PointerBase; +import org.slf4j.LoggerFactory; + +@CContext(WindowsTrashBin.ShellApi.class) +final class WindowsTrashBin implements TrashBin { + @CConstant + public static native int FO_DELETE(); + + @CConstant + public static native int FOF_ALLOWUNDO(); + + @CConstant + public static native int FOF_NO_UI(); + + @Override + public boolean isSupported() { + return true; + } + + @Override + public boolean moveToTrash(Path path) { + if (Platform.getOperatingSystem().isWindows()) { + try { + return moveToTrashImpl(path); + } catch (NullPointerException | LinkageError err) { + if (!Boolean.getBoolean("com.oracle.graalvm.isaot")) { + var logger = LoggerFactory.getLogger(MacTrashBin.class); + logger.warn("Moving to Windows' Trash Bin is not supported in non-AOT mode."); + return false; + } else { + throw err; + } + } + } else { + return false; + } + } + + private boolean moveToTrashImpl(Path path) { + try (var cPath = CTypeConversion.toCString(path.toString() + "\0")) { + var fileop = StackValue.get(SHFileOperation.class); + fileop.wFunc(FO_DELETE()); + fileop.pFrom(cPath.get()); + fileop.fFlags((short) (FOF_ALLOWUNDO() | FOF_NO_UI())); + var res = SHFileOperationA(fileop); + return res == 0; + } catch (Throwable error) { + return false; + } + } + + @CStruct(value = "SHFILEOPSTRUCTA") + static interface SHFileOperation extends PointerBase { + @CField + void pFrom(CCharPointer from); + + @CField + void wFunc(int op); + + @CField + void fFlags(short flags); + } + + @CFunction + static native int SHFileOperationA(SHFileOperation fileop); + + public static final class ShellApi implements CContext.Directives { + + @Override + public boolean isInConfiguration() { + return Platform.getOperatingSystem().isWindows(); + } + + @Override + public List getHeaderFiles() { + return List.of(""); + } + + @Override + public List getLibraries() { + return List.of("shell32"); + } + } +} diff --git a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java index cefb3d44bb8..84d5b18355f 100644 --- a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java +++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java @@ -3,17 +3,11 @@ package org.enso.desktopenvironment; import java.io.IOException; import java.nio.file.Files; import org.junit.Assert; -import org.junit.BeforeClass; import org.junit.Test; public class DirectoriesTest { - private static Directories directories; - - @BeforeClass - public static void setup() { - directories = Platform.getDirectories(); - } + private static final Directories directories = Platform.getOperatingSystem().getDirectories(); @Test public void getUserHome() { @@ -24,10 +18,10 @@ public class DirectoriesTest { @Test public void getDocuments() throws IOException { // getDocuments fails on Windows CI - if (!Platform.isWindows()) { - var documents = directories.getDocuments(); - Assert.assertTrue( - "User documents is not a directory" + documents, Files.isDirectory(documents)); - } + if (Platform.getOperatingSystem().isWindows()) return; + + var documents = directories.getDocuments(); + Assert.assertTrue( + "User documents is not a directory" + documents, Files.isDirectory(documents)); } } diff --git a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java index 1b8f7de1719..2b16740074c 100644 --- a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java +++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java @@ -5,8 +5,18 @@ import org.junit.Test; public class PlatformTest { + @Test + public void getOperatingSystem() { + Assert.assertNotNull(Platform.getOperatingSystem()); + } + @Test public void getDirectories() { - Assert.assertNotNull(Platform.getDirectories()); + Assert.assertNotNull(Platform.getOperatingSystem().getDirectories()); + } + + @Test + public void getTrashBin() { + Assert.assertNotNull(Platform.getOperatingSystem().getTrashBin()); } } diff --git a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/RandomUtilsTest.java b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/RandomUtilsTest.java new file mode 100644 index 00000000000..21a93a61227 --- /dev/null +++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/RandomUtilsTest.java @@ -0,0 +1,45 @@ +package org.enso.desktopenvironment; + +import org.junit.Assert; +import org.junit.Test; + +public class RandomUtilsTest { + + @Test + public void alphanumericString() { + var size = Short.MAX_VALUE; + var str = RandomUtils.alphanumericString(size); + Assert.assertEquals(size, str.length()); + Assert.assertTrue(isAsciiAlphanumeric(str)); + } + + @Test + public void alphanumericStringEmpty() { + var size = 0; + var str = RandomUtils.alphanumericString(size); + Assert.assertEquals(size, str.length()); + } + + @Test + public void alphanumericStringNegativeSize() { + Assert.assertThrows(IllegalArgumentException.class, () -> RandomUtils.alphanumericString(-1)); + } + + private static boolean isAsciiAlphanumeric(String str) { + boolean result = true; + for (int i = 0; i < str.length(); i++) { + var c = str.charAt(i); + result = result && (isAsciiLetter(c) || isAsciiDigit(c)); + } + + return result; + } + + private static boolean isAsciiLetter(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } + + private static boolean isAsciiDigit(char c) { + return c >= '0' && c <= '9'; + } +} diff --git a/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/TrashBinTest.java b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/TrashBinTest.java new file mode 100644 index 00000000000..a1f85417271 --- /dev/null +++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/TrashBinTest.java @@ -0,0 +1,87 @@ +package org.enso.desktopenvironment; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class TrashBinTest { + + private static final TrashBin TRASH_BIN = Platform.getOperatingSystem().getTrashBin(); + + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void isSupported() { + if (isEnabled()) { + Assert.assertTrue(TRASH_BIN.isSupported()); + } + } + + @Test + public void moveToTrashFile() throws IOException { + if (isEnabled()) { + var path = createTempFile(temporaryFolder); + + Assert.assertTrue(TRASH_BIN.moveToTrash(path)); + Assert.assertFalse(TRASH_BIN.moveToTrash(path)); + } + } + + @Test + public void moveToTrashSameFile() throws IOException { + if (isEnabled()) { + var path = createTempFile(temporaryFolder); + + Assert.assertTrue(TRASH_BIN.moveToTrash(path)); + + Files.writeString(path, ""); + Assert.assertTrue(TRASH_BIN.moveToTrash(path)); + } + } + + @Test + public void moveToTrashDirectory() throws IOException { + if (isEnabled()) { + var path = createTempDirectory(temporaryFolder); + Files.writeString(path.resolve("moveToTrashDirectory"), ""); + + Assert.assertTrue(TRASH_BIN.moveToTrash(path)); + Assert.assertFalse(TRASH_BIN.moveToTrash(path)); + } + } + + @Test + public void moveToTrashSameDirectory() throws IOException { + if (isEnabled()) { + var path = createTempDirectory(temporaryFolder); + Files.writeString(path.resolve("moveToTrashSameDirectory"), ""); + + Assert.assertTrue(TRASH_BIN.moveToTrash(path)); + + Files.createDirectory(path); + Files.writeString(path.resolve("moveToTrashSameDirectory"), ""); + Assert.assertTrue(TRASH_BIN.moveToTrash(path)); + } + } + + /** + * Check if the test is enabled. + * + *

macOS and Windows trash bin implementation only works in Native Image. + */ + private static boolean isEnabled() { + return Platform.getOperatingSystem().isLinux(); + } + + private static Path createTempFile(TemporaryFolder temporaryFolder) throws IOException { + return temporaryFolder.newFile().toPath(); + } + + private static Path createTempDirectory(TemporaryFolder temporaryFolder) throws IOException { + return temporaryFolder.newFolder("TrashTest").toPath(); + } +} diff --git a/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java b/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java index b98d21a0bd7..8852db8aa93 100644 --- a/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java +++ b/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java @@ -21,7 +21,12 @@ public final class ProjectsMigration { public static void migrate(configuration.StorageConfig storageConfig) { var oldProjectsPath = - Platform.getDirectories().getUserHome().resolve("enso").resolve("projects").toFile(); + Platform.getOperatingSystem() + .getDirectories() + .getUserHome() + .resolve("enso") + .resolve("projects") + .toFile(); if (oldProjectsPath.isDirectory()) { try { File newProjectsPath = storageConfig.userProjectsPath(); @@ -63,7 +68,7 @@ public final class ProjectsMigration { return; } - if (!Platform.isWindows()) { + if (!Platform.getOperatingSystem().isWindows()) { try { logger.info("Setting projects directory permissions '{}'.", newProjectsPath); setProjectsDirectoryPermissions(newProjectsPath); diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala index 99a844cbc60..9c67770b1a6 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/MainModule.scala @@ -11,6 +11,7 @@ import org.enso.projectmanager.boot.configuration.{ } import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{Async, ErrorChannel, Exec, Sync} +import org.enso.projectmanager.infrastructure.desktop.{DesktopTrash, TrashCan} import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.http.ProjectsEndpoint import org.enso.projectmanager.infrastructure.languageserver.{ @@ -68,8 +69,16 @@ class MainModule[ lazy val projectValidator = new ProjectNameValidator[F]() + lazy val trash: TrashCan[F] = DesktopTrash[F] + lazy val projectRepositoryFactory = - new ProjectFileRepositoryFactory[F](config.storage, clock, fileSystem, gen) + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val distributionConfiguration = DefaultDistributionConfiguration val loggingService = Logging.GlobalLoggingService diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala index 92afe861541..83a24fc47f5 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/ProjectListCommand.scala @@ -5,6 +5,7 @@ import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.effect.syntax._ import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.DesktopTrash import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.{ @@ -47,13 +48,16 @@ object ProjectListCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] + val trash = DesktopTrash[F] - val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( - config.storage, - clock, - fileSystem, - gen - ) + val projectRepositoryFactory = + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val projectRepository = projectRepositoryFactory.getProjectRepository(projectsPath) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemCreateDirectoryCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemCreateDirectoryCommand.scala index 1cc0a2e4552..88e5200fdc6 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemCreateDirectoryCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemCreateDirectoryCommand.scala @@ -4,6 +4,7 @@ import org.enso.projectmanager.boot.configuration.ProjectManagerConfig import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.DesktopTrash import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory @@ -35,12 +36,16 @@ object FileSystemCreateDirectoryCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] - val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( - config.storage, - clock, - fileSystem, - gen - ) + val trash = DesktopTrash[F] + + val projectRepositoryFactory = + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val service = new FileSystemService[F](fileSystem, projectRepositoryFactory) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemDeleteCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemDeleteCommand.scala index c0f932fde60..8298656a98b 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemDeleteCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemDeleteCommand.scala @@ -4,6 +4,7 @@ import org.enso.projectmanager.boot.configuration.ProjectManagerConfig import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.DesktopTrash import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory @@ -35,12 +36,16 @@ object FileSystemDeleteCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] - val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( - config.storage, - clock, - fileSystem, - gen - ) + val trash = DesktopTrash[F] + + val projectRepositoryFactory = + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val service = new FileSystemService[F](fileSystem, projectRepositoryFactory) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemExistsCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemExistsCommand.scala index 88f50cd1f13..ed1d9d98a9e 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemExistsCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemExistsCommand.scala @@ -4,6 +4,7 @@ import org.enso.projectmanager.boot.configuration.ProjectManagerConfig import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.DesktopTrash import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory @@ -36,12 +37,16 @@ object FileSystemExistsCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] - val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( - config.storage, - clock, - fileSystem, - gen - ) + val trash = DesktopTrash[F] + + val projectRepositoryFactory = + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val service = new FileSystemService[F](fileSystem, projectRepositoryFactory) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemListCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemListCommand.scala index 80bfbdc6b5c..2af40ea2f46 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemListCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemListCommand.scala @@ -4,6 +4,7 @@ import org.enso.projectmanager.boot.configuration.ProjectManagerConfig import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.DesktopTrash import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory @@ -38,12 +39,16 @@ object FileSystemListCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] - val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( - config.storage, - clock, - fileSystem, - gen - ) + val trash = DesktopTrash[F] + + val projectRepositoryFactory = + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val service = new FileSystemService[F](fileSystem, projectRepositoryFactory) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemMoveDirectoryCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemMoveDirectoryCommand.scala index 4146bf29492..246db937d56 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemMoveDirectoryCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemMoveDirectoryCommand.scala @@ -4,6 +4,7 @@ import org.enso.projectmanager.boot.configuration.ProjectManagerConfig import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.DesktopTrash import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory @@ -37,12 +38,16 @@ object FileSystemMoveDirectoryCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] - val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( - config.storage, - clock, - fileSystem, - gen - ) + val trash = DesktopTrash[F] + + val projectRepositoryFactory = + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val service = new FileSystemService[F](fileSystem, projectRepositoryFactory) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemWritePathCommand.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemWritePathCommand.scala index 766ade01496..ccb4085ce23 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemWritePathCommand.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/command/filesystem/FileSystemWritePathCommand.scala @@ -4,6 +4,7 @@ import org.enso.projectmanager.boot.configuration.ProjectManagerConfig import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.DesktopTrash import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory @@ -36,12 +37,16 @@ object FileSystemWritePathCommand { val clock = new RealClock[F] val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val gen = new SystemGenerator[F] - val projectRepositoryFactory = new ProjectFileRepositoryFactory[F]( - config.storage, - clock, - fileSystem, - gen - ) + val trash = DesktopTrash[F] + + val projectRepositoryFactory = + new ProjectFileRepositoryFactory[F]( + config.storage, + clock, + fileSystem, + gen, + trash + ) val service = new FileSystemService[F](fileSystem, projectRepositoryFactory) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala index af220fd6481..9ccaf55cd66 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/configuration.scala @@ -69,7 +69,9 @@ object configuration { @throws[IOException] def userProjectsPath: File = { val projectsRootDirectory = - projectsRoot.getOrElse(Platform.getDirectories.getDocuments.toFile) + projectsRoot.getOrElse( + Platform.getOperatingSystem.getDirectories.getDocuments.toFile + ) new File(projectsRootDirectory, projectsDirectory) } } diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/DesktopTrash.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/DesktopTrash.scala new file mode 100644 index 00000000000..4afdb4d0d65 --- /dev/null +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/DesktopTrash.scala @@ -0,0 +1,20 @@ +package org.enso.projectmanager.infrastructure.desktop + +import org.enso.desktopenvironment.{Platform, TrashBin} +import org.enso.projectmanager.control.effect.Sync + +import java.io.File + +class DesktopTrash[F[+_, +_]: Sync](trash: TrashBin) extends TrashCan[F] { + + /** @inheritdoc */ + override def moveToTrash(path: File): F[Nothing, Boolean] = + Sync[F].effect(trash.moveToTrash(path.toPath)) +} + +object DesktopTrash { + + def apply[F[+_, +_]: Sync]: DesktopTrash[F] = { + new DesktopTrash(Platform.getOperatingSystem.getTrashBin) + } +} diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/TrashCan.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/TrashCan.scala new file mode 100644 index 00000000000..f396d401387 --- /dev/null +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/desktop/TrashCan.scala @@ -0,0 +1,16 @@ +package org.enso.projectmanager.infrastructure.desktop + +import java.io.File + +/** An abstraction form the trash can. + * + * @tparam F a monadic context + */ +trait TrashCan[F[+_, +_]] { + + /** Moves the provided path to the trash bin. + * + * @return `true` if the operation was successful + */ + def moveToTrash(path: File): F[Nothing, Boolean] +} diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala index d56924b2c5d..e7895797e72 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala @@ -15,6 +15,7 @@ import org.enso.projectmanager.control.core.{ import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.effect.syntax._ import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.TrashCan import org.enso.projectmanager.infrastructure.file.FileSystem import org.enso.projectmanager.infrastructure.file.FileSystemFailure.{ FileNotFound, @@ -35,6 +36,7 @@ import org.enso.projectmanager.model.{Project, ProjectMetadata} * @param clock a clock * @param fileSystem a file system abstraction * @param gen a random generator + * @param trash a trash can */ class ProjectFileRepository[ F[+_, +_]: Sync: ErrorChannel: CovariantFlatMap: Applicative @@ -43,7 +45,8 @@ class ProjectFileRepository[ metadataStorageConfig: MetadataStorageConfig, clock: Clock[F], fileSystem: FileSystem[F], - gen: Generator[F] + gen: Generator[F], + trash: TrashCan[F] ) extends ProjectRepository[F] { /** @inheritdoc */ @@ -238,6 +241,19 @@ class ProjectFileRepository[ } } + /** @inheritdoc */ + override def moveToTrash( + projectId: UUID + ): F[ProjectRepositoryFailure, Boolean] = { + findById(projectId) + .flatMap { + case Some(project) => + trash.moveToTrash(project.path) + case None => + ErrorChannel[F].fail(ProjectNotFoundInIndex) + } + } + /** @inheritdoc */ override def moveProject( projectId: UUID, diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala index 02204f75521..87931b6672d 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepositoryFactory.scala @@ -2,6 +2,7 @@ package org.enso.projectmanager.infrastructure.repository import org.enso.projectmanager.boot.configuration.StorageConfig import org.enso.projectmanager.control.core.{Applicative, CovariantFlatMap} import org.enso.projectmanager.control.effect.{ErrorChannel, Sync} +import org.enso.projectmanager.infrastructure.desktop.TrashCan import org.enso.projectmanager.infrastructure.file.FileSystem import org.enso.projectmanager.infrastructure.random.Generator import org.enso.projectmanager.infrastructure.time.Clock @@ -14,7 +15,8 @@ class ProjectFileRepositoryFactory[ storageConfig: StorageConfig, clock: Clock[F], fileSystem: FileSystem[F], - gen: Generator[F] + gen: Generator[F], + trash: TrashCan[F] ) extends ProjectRepositoryFactory[F] { /** @inheritdoc */ @@ -28,7 +30,8 @@ class ProjectFileRepositoryFactory[ storageConfig.metadata, clock, fileSystem, - gen + gen, + trash ) } diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala index 2b94fb4de6f..f49f094bbfc 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala @@ -44,6 +44,13 @@ trait ProjectRepository[F[+_, +_]] { */ def delete(projectId: UUID): F[ProjectRepositoryFailure, Unit] + /** Move the provided project to the trash bin. + * + * @param projectId the project id trash + * @return `true` if the operation was successful + */ + def moveToTrash(projectId: UUID): F[ProjectRepositoryFailure, Boolean] + /** Renames a project. * * @param projectId the project id to rename diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala index 8491e866a36..b813e6e57f0 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala @@ -144,14 +144,26 @@ class ProjectService[ override def deleteUserProject( projectId: UUID, projectsDirectory: Option[File] - ): F[ProjectServiceFailure, Unit] = - log.debug("Deleting project [{}].", projectId) *> - ensureProjectIsNotRunning(projectId) *> - projectRepositoryFactory - .getProjectRepository(projectsDirectory) - .delete(projectId) - .mapError(toServiceFailure) *> - log.info("Project deleted [{}].", projectId) + ): F[ProjectServiceFailure, Unit] = { + val projectRepository = + projectRepositoryFactory.getProjectRepository(projectsDirectory) + val moveProjectToTrash = + projectRepository.moveToTrash(projectId).mapError(toServiceFailure) + val deleteProject = + projectRepository.delete(projectId).mapError(toServiceFailure) + + for { + _ <- log.debug("Preparing to delete the project [{}].", projectId) + _ <- ensureProjectIsNotRunning(projectId) + _ <- CovariantFlatMap[F].ifM(moveProjectToTrash)( + ifTrue = log.info("Project moved to trash [{}].", projectId), + ifFalse = + log.info("Failed to trash the project. Deleting [{}].", projectId) *> + deleteProject *> + log.info("Project deleted [{}]", projectId) + ) + } yield () + } private def ensureProjectIsNotRunning( projectId: UUID diff --git a/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java b/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java index 02d1651c2f9..43f71dd6842 100644 --- a/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java +++ b/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java @@ -38,7 +38,7 @@ public class ProjectsMigrationTest { @Test public void setProjectDirectoryPermissions() throws IOException { - if (!Platform.isWindows()) { + if (!Platform.getOperatingSystem().isWindows()) { File projectsDir = tmp.newFolder("projects"); createProjectStructure(projectsDir, "Project1"); diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala index 277abb66ad4..ece2d108cd9 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/BaseServerSpec.scala @@ -42,7 +42,11 @@ import org.enso.projectmanager.service.versionmanagement.{ RuntimeVersionManagerFactory } import org.enso.projectmanager.service.{ProjectCreationService, ProjectService} -import org.enso.projectmanager.test.{ObservableGenerator, ProgrammableClock} +import org.enso.projectmanager.test.{ + ObservableGenerator, + ProgrammableClock, + Shredder +} import org.enso.runtimeversionmanager.CurrentVersion import org.enso.runtimeversionmanager.components.GraalVMVersion import org.enso.runtimeversionmanager.test.FakeReleases @@ -102,6 +106,8 @@ class BaseServerSpec extends JsonRpcServerTestKit with BeforeAndAfterAll { lazy val gen = new ObservableGenerator[ZAny]() + lazy val trash = new Shredder[ZAny] + val testProjectsRoot = Files.createTempDirectory(null).toFile sys.addShutdownHook(FileUtils.deleteQuietly(testProjectsRoot)) @@ -141,7 +147,8 @@ class BaseServerSpec extends JsonRpcServerTestKit with BeforeAndAfterAll { testStorageConfig, testClock, fileSystem, - gen + gen, + trash ) lazy val projectNameValidator = new ProjectNameValidator[ZIO[ZAny, *, *]]() diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala index 147802219fd..5ecb63428aa 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala @@ -35,7 +35,8 @@ class FileSystemServiceSpec config.storage, testClock, fileSystem, - gen + gen, + trash ) new FileSystemService[ZIO[ZAny, +*, +*]]( fileSystem, diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/test/Shredder.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/test/Shredder.scala new file mode 100644 index 00000000000..080efc804bd --- /dev/null +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/test/Shredder.scala @@ -0,0 +1,19 @@ +package org.enso.projectmanager.test + +import org.apache.commons.io.FileUtils +import org.enso.projectmanager.infrastructure.desktop.TrashCan +import zio.ZIO + +import java.io.File + +class Shredder[R] extends TrashCan[ZIO[R, +*, +*]] { + + /** @inheritdoc */ + override def moveToTrash(path: File): ZIO[R, Nothing, Boolean] = + ZIO + .attemptBlocking(FileUtils.forceDelete(path)) + .fold( + failure = _ => false, + success = _ => true + ) +}