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
This commit is contained in:
Dmitry Bushev 2024-07-27 16:37:43 +03:00 committed by GitHub
parent 9e19009158
commit 446834b4f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 906 additions and 118 deletions

View File

@ -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
)
)

View File

@ -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;
}
}

View File

@ -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 <a
* href="https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html">FreeDesktop.org Trash
* specification</a>.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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);
}
}
}
}

View File

@ -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<String> getHeaderFiles() {
return List.of("<CoreServices/CoreServices.h>");
}
@Override
public List<String> getLibraries() {
return List.of("-framework CoreServices");
}
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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<String> getHeaderFiles() {
return List.of("<windows.h>");
}
@Override
public List<String> getLibraries() {
return List.of("shell32");
}
}
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View File

@ -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';
}
}

View File

@ -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.
*
* <p>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();
}
}

View File

@ -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);

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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]
}

View File

@ -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,

View File

@ -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
)
}

View File

@ -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

View File

@ -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

View File

@ -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");

View File

@ -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, *, *]]()

View File

@ -35,7 +35,8 @@ class FileSystemServiceSpec
config.storage,
testClock,
fileSystem,
gen
gen,
trash
)
new FileSystemService[ZIO[ZAny, +*, +*]](
fileSystem,

View File

@ -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
)
}