diff --git a/app/ide-desktop/lib/client/src/bin/server.ts b/app/ide-desktop/lib/client/src/bin/server.ts
index afbaeb8ddf7..bebd8d22cef 100644
--- a/app/ide-desktop/lib/client/src/bin/server.ts
+++ b/app/ide-desktop/lib/client/src/bin/server.ts
@@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises'
import * as fsSync from 'node:fs'
import * as http from 'node:http'
-import * as os from 'node:os'
import * as path from 'node:path'
import * as stream from 'node:stream'
import * as mkcert from 'mkcert'
@@ -17,6 +16,7 @@ import * as common from 'enso-common'
import GLOBAL_CONFIG from 'enso-common/src/config.json' assert { type: 'json' }
import * as contentConfig from 'enso-content-config'
import * as ydocServer from 'enso-gui2/ydoc-server'
+import * as projectManagement from 'enso-project-manager-shim/src/projectManagement'
import * as paths from '../paths'
@@ -92,7 +92,7 @@ export class Server {
/** Create a simple HTTP server. */
constructor(public config: Config) {
- this.projectsRootDirectory = path.join(os.homedir(), 'enso/projects')
+ this.projectsRootDirectory = projectManagement.getProjectsDirectory()
}
/** Server constructor. */
diff --git a/app/ide-desktop/lib/project-manager-shim/package.json b/app/ide-desktop/lib/project-manager-shim/package.json
index 7ea1db9bf7d..4fb055ea882 100644
--- a/app/ide-desktop/lib/project-manager-shim/package.json
+++ b/app/ide-desktop/lib/project-manager-shim/package.json
@@ -3,7 +3,8 @@
"version": "1.0.0",
"type": "module",
"exports": {
- ".": "./src/projectManagerShimMiddleware.ts"
+ ".": "./src/projectManagerShimMiddleware.ts",
+ "./src/projectManagement": "./src/projectManagement.ts"
},
"dependencies": {
"yaml": "^2.4.1"
diff --git a/app/ide-desktop/lib/project-manager-shim/src/desktopEnvironment.ts b/app/ide-desktop/lib/project-manager-shim/src/desktopEnvironment.ts
new file mode 100644
index 00000000000..760a8dd942b
--- /dev/null
+++ b/app/ide-desktop/lib/project-manager-shim/src/desktopEnvironment.ts
@@ -0,0 +1,81 @@
+/**
+ * @file This module contains the logic for the detection of user-specific desktop environment attributes.
+ */
+
+import * as childProcess from 'node:child_process'
+import * as os from 'node:os'
+import * as path from 'node:path'
+
+export const DOCUMENTS = getDocumentsPath()
+
+const CHILD_PROCESS_TIMEOUT = 3000
+
+/**
+ * Detects path of the user documents directory depending on the operating system.
+ */
+function getDocumentsPath(): string | undefined {
+ if (process.platform === 'linux') {
+ return getLinuxDocumentsPath()
+ } else if (process.platform === 'darwin') {
+ return getMacOsDocumentsPath()
+ } else if (process.platform === 'win32') {
+ return getWindowsDocumentsPath()
+ } else {
+ return
+ }
+}
+
+/**
+ * Returns the user documents path on Linux.
+ */
+function getLinuxDocumentsPath(): string {
+ const xdgDocumentsPath = getXdgDocumentsPath()
+
+ return xdgDocumentsPath ?? path.join(os.homedir(), 'enso')
+}
+
+/**
+ * Gets the documents directory from the XDG directory management system.
+ */
+function getXdgDocumentsPath(): string | undefined {
+ const out = childProcess.spawnSync('xdg-user-dir', ['DOCUMENTS'], {
+ timeout: CHILD_PROCESS_TIMEOUT,
+ })
+
+ if (out.error !== undefined) {
+ return
+ } else {
+ return out.stdout.toString().trim()
+ }
+}
+
+/**
+ * Get the user documents path. On macOS, `Documents` acts as a symlink pointing to the
+ * real locale-specific user documents directory.
+ */
+function getMacOsDocumentsPath(): string {
+ return path.join(os.homedir(), 'Documents')
+}
+
+/**
+ * Get the path to the `My Documents` Windows directory.
+ */
+function getWindowsDocumentsPath(): string | undefined {
+ const out = childProcess.spawnSync(
+ 'reg',
+ [
+ 'query',
+ '"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\ShellFolders"',
+ '/v',
+ 'personal',
+ ],
+ { timeout: CHILD_PROCESS_TIMEOUT }
+ )
+
+ if (out.error !== undefined) {
+ return
+ } else {
+ const stdoutString = out.stdout.toString()
+ return stdoutString.split('\\s\\s+')[4]
+ }
+}
diff --git a/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts b/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts
index 0cdd6f8eca0..52f184f78fb 100644
--- a/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts
+++ b/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts
@@ -17,6 +17,7 @@ import * as tar from 'tar'
import * as common from 'enso-common'
import * as buildUtils from 'enso-common/src/buildUtils'
+import * as desktopEnvironment from './desktopEnvironment'
const logger = console
@@ -369,7 +370,12 @@ export function getProjectRoot(subtreePath: string): string | null {
/** Get the directory that stores Enso projects. */
export function getProjectsDirectory(): string {
- return pathModule.join(os.homedir(), 'enso', 'projects')
+ const documentsPath = desktopEnvironment.DOCUMENTS
+ if (documentsPath === undefined) {
+ return pathModule.join(os.homedir(), 'enso', 'projects')
+ } else {
+ return pathModule.join(documentsPath, 'enso-projects')
+ }
}
/** Check if the given project is installed, i.e. can be opened with the Project Manager. */
diff --git a/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts b/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts
index 66e9e6d93a8..ce07aac80dc 100644
--- a/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts
+++ b/app/ide-desktop/lib/project-manager-shim/src/projectManagerShimMiddleware.ts
@@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises'
import * as fsSync from 'node:fs'
import * as http from 'node:http'
-import * as os from 'node:os'
import * as path from 'node:path'
import * as isHiddenFile from 'is-hidden-file'
@@ -22,7 +21,7 @@ import * as projectManagement from './projectManagement'
const HTTP_STATUS_OK = 200
const HTTP_STATUS_BAD_REQUEST = 400
const HTTP_STATUS_NOT_FOUND = 404
-const PROJECTS_ROOT_DIRECTORY = path.join(os.homedir(), 'enso/projects')
+const PROJECTS_ROOT_DIRECTORY = projectManagement.getProjectsDirectory()
// =============
// === Types ===
diff --git a/build.sbt b/build.sbt
index e9bcb8d1ed4..2aac1d07449 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1120,6 +1120,7 @@ lazy val `project-manager` = (project in file("lib/scala/project-manager"))
.value
)
.dependsOn(`akka-native`)
+ .dependsOn(`desktop-environment`)
.dependsOn(`version-output`)
.dependsOn(editions)
.dependsOn(`edition-updater`)
@@ -2797,6 +2798,17 @@ lazy val `benchmarks-common` =
)
.dependsOn(`polyglot-api`)
+lazy val `desktop-environment` =
+ project
+ .in(file("lib/java/desktop-environment"))
+ .settings(
+ frgaalJavaCompilerSetting,
+ libraryDependencies ++= Seq(
+ "junit" % "junit" % junitVersion % Test,
+ "com.github.sbt" % "junit-interface" % junitIfVersion % Test
+ )
+ )
+
lazy val `bench-processor` = (project in file("lib/scala/bench-processor"))
.settings(
frgaalJavaCompilerSetting,
diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Directories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Directories.java
new file mode 100644
index 00000000000..54ed82110cb
--- /dev/null
+++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Directories.java
@@ -0,0 +1,21 @@
+package org.enso.desktopenvironment;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/** Provides information about user directories. */
+public sealed interface Directories permits LinuxDirectories, MacOsDirectories, WindowsDirectories {
+
+ /**
+ * @return the user home directory.
+ */
+ default Path getUserHome() {
+ return Path.of(System.getProperty("user.home"));
+ }
+
+ /**
+ * @return the user documents directory.
+ * @throws IOException when cannot detect the documents directory of the user.
+ */
+ Path getDocuments() throws IOException;
+}
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
new file mode 100644
index 00000000000..e6f37e9bece
--- /dev/null
+++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/DirectoriesFactory.java
@@ -0,0 +1,28 @@
+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/LinuxDirectories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxDirectories.java
new file mode 100644
index 00000000000..c005bac8b35
--- /dev/null
+++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/LinuxDirectories.java
@@ -0,0 +1,38 @@
+package org.enso.desktopenvironment;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+final class LinuxDirectories implements Directories {
+
+ private static final String[] PROCESS_XDG_DOCUMENTS = new String[] {"xdg-user-dir", "DOCUMENTS"};
+
+ /**
+ * Get the user 'Documents' directory.
+ *
+ *
Tries to obtain the documents directory from the XDG directory management system if
+ * available and falls back to {@code $HOME/enso}.
+ *
+ * @return the path to the user documents directory.
+ */
+ @Override
+ public Path getDocuments() {
+ try {
+ return getXdgDocuments();
+ } catch (IOException | InterruptedException e) {
+ return getUserHome().resolve("enso");
+ }
+ }
+
+ private Path getXdgDocuments() throws IOException, InterruptedException {
+ var process = new ProcessBuilder(PROCESS_XDG_DOCUMENTS).start();
+ process.waitFor(3, TimeUnit.SECONDS);
+
+ var documentsString =
+ new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+ return Path.of(documentsString.trim());
+ }
+}
diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacOsDirectories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacOsDirectories.java
new file mode 100644
index 00000000000..dc689bb6e2a
--- /dev/null
+++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/MacOsDirectories.java
@@ -0,0 +1,27 @@
+package org.enso.desktopenvironment;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+final class MacOsDirectories implements Directories {
+
+ private static final String DOCUMENTS = "Documents";
+
+ /**
+ * Get the user documents path.
+ *
+ *
On macOS, the 'Documents' directory acts like a symlink and points to the real
+ * locale-dependent user documents folder.
+ *
+ * @return the path to the user documents directory.
+ * @throws IOException when unable to resolve the real documents path.
+ */
+ @Override
+ public Path getDocuments() throws IOException {
+ try {
+ return getUserHome().resolve(DOCUMENTS).toRealPath();
+ } catch (IOException e) {
+ throw new IOException("Failed to resolve real MacOs documents path", e);
+ }
+ }
+}
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
new file mode 100644
index 00000000000..2bc20350dc8
--- /dev/null
+++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/Platform.java
@@ -0,0 +1,31 @@
+package org.enso.desktopenvironment;
+
+public final class Platform {
+
+ 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 Platform() {}
+
+ public static String getOsName() {
+ return System.getProperty(OS_NAME);
+ }
+
+ public static boolean isLinux() {
+ return getOsName().toLowerCase().contains(LINUX);
+ }
+
+ public static boolean isMacOs() {
+ return getOsName().toLowerCase().contains(MAC);
+ }
+
+ public static boolean isWindows() {
+ return getOsName().toLowerCase().contains(WINDOWS);
+ }
+
+ public static Directories getDirectories() {
+ return DirectoriesFactory.getInstance();
+ }
+}
diff --git a/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsDirectories.java b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsDirectories.java
new file mode 100644
index 00000000000..195a2b7d0f7
--- /dev/null
+++ b/lib/java/desktop-environment/src/main/java/org/enso/desktopenvironment/WindowsDirectories.java
@@ -0,0 +1,47 @@
+package org.enso.desktopenvironment;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+final class WindowsDirectories implements Directories {
+
+ private static final String[] PROCESS_REG_QUERY =
+ new String[] {
+ "reg",
+ "query",
+ "\"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\ShellFolders\"",
+ "/v",
+ "personal"
+ };
+
+ /**
+ * Get the path to 'My Documents' user directory.
+ *
+ *
Method uses the registry query that may not work on Windows XP versions and below.
+ *
+ * @return the 'My Documents' user directory path.
+ * @throws IOException when fails to detect the user documents directory.
+ */
+ @Override
+ public Path getDocuments() throws IOException {
+ try {
+ var process = new ProcessBuilder(PROCESS_REG_QUERY).start();
+ process.waitFor(3, TimeUnit.SECONDS);
+
+ var stdoutString =
+ new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ var stdoutParts = stdoutString.split("\\s\\s+");
+ if (stdoutParts.length < 5) {
+ throw new IOException("Invalid Windows registry query output: '" + stdoutString + "'");
+ }
+
+ return Path.of(stdoutParts[4].trim());
+ } catch (IOException e) {
+ throw new IOException("Failed to run Windows registry query", e);
+ } catch (InterruptedException e) {
+ throw new IOException("Windows registry query timeout", e);
+ }
+ }
+}
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
new file mode 100644
index 00000000000..19810f8d65c
--- /dev/null
+++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/DirectoriesTest.java
@@ -0,0 +1,30 @@
+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();
+ }
+
+ @Test
+ public void getUserHome() {
+ var userHome = directories.getUserHome();
+ Assert.assertTrue("User home is not a directory: " + userHome, Files.isDirectory(userHome));
+ }
+
+ @Test
+ public void getDocuments() throws IOException {
+ 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
new file mode 100644
index 00000000000..1b8f7de1719
--- /dev/null
+++ b/lib/java/desktop-environment/src/test/java/org/enso/desktopenvironment/PlatformTest.java
@@ -0,0 +1,12 @@
+package org.enso.desktopenvironment;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class PlatformTest {
+
+ @Test
+ public void getDirectories() {
+ Assert.assertNotNull(Platform.getDirectories());
+ }
+}
diff --git a/lib/scala/project-manager/src/main/java/org/enso/projectmanager/event/package-info.java b/lib/scala/project-manager/src/main/java/org/enso/projectmanager/event/package-info.java
deleted file mode 100644
index 3dcf3832573..00000000000
--- a/lib/scala/project-manager/src/main/java/org/enso/projectmanager/event/package-info.java
+++ /dev/null
@@ -1 +0,0 @@
-package org.enso.projectmanager.event;
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
new file mode 100644
index 00000000000..b98d21a0bd7
--- /dev/null
+++ b/lib/scala/project-manager/src/main/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigration.java
@@ -0,0 +1,118 @@
+package org.enso.projectmanager.infrastructure.migration;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.commons.io.FileUtils;
+import org.enso.desktopenvironment.Platform;
+import org.enso.projectmanager.boot.configuration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class ProjectsMigration {
+
+ private static final Logger logger = LoggerFactory.getLogger(ProjectsMigration.class);
+
+ private ProjectsMigration() {}
+
+ public static void migrate(configuration.StorageConfig storageConfig) {
+ var oldProjectsPath =
+ Platform.getDirectories().getUserHome().resolve("enso").resolve("projects").toFile();
+ if (oldProjectsPath.isDirectory()) {
+ try {
+ File newProjectsPath = storageConfig.userProjectsPath();
+ migrateProjectsDirectory(oldProjectsPath, newProjectsPath);
+ } catch (IOException e) {
+ logger.error("Migration aborted. Failed to get user documents directory.", e);
+ }
+ }
+ }
+
+ static void migrateProjectsDirectory(File oldProjectsPath, File newProjectsPath) {
+ if (oldProjectsPath.isDirectory()) {
+ logger.info(
+ "Running projects migration from '{}' to '{}'.", oldProjectsPath, newProjectsPath);
+ if (newProjectsPath.isDirectory()) {
+ try {
+ logger.info(
+ "Both '{}' and '{}' project directories exist. Cleaning up.",
+ oldProjectsPath,
+ newProjectsPath);
+ FileUtils.deleteDirectory(oldProjectsPath);
+ return;
+ } catch (IOException e) {
+ logger.error(
+ "Both '{}' and '{}' project directories exist. Failed to clean up.",
+ oldProjectsPath,
+ newProjectsPath,
+ e);
+ return;
+ }
+ }
+
+ try {
+ logger.info(
+ "Moving projects directory from '{}' to '{}'.", oldProjectsPath, newProjectsPath);
+ moveDirectory(oldProjectsPath, newProjectsPath);
+ } catch (IOException ex) {
+ logger.error("Migration aborted. Failed to copy user projects directory.", ex);
+ return;
+ }
+
+ if (!Platform.isWindows()) {
+ try {
+ logger.info("Setting projects directory permissions '{}'.", newProjectsPath);
+ setProjectsDirectoryPermissions(newProjectsPath);
+ } catch (IOException ex) {
+ logger.error(
+ "Failed to set permissions on projects directory '{}'.", newProjectsPath, ex);
+ return;
+ }
+ }
+
+ logger.info("Projects migration successful.");
+ }
+ }
+
+ /**
+ * Moves the source directory to the destination directory by renaming the source directory. If
+ * renaming is not supported by the file system or the destination directory is located on a
+ * different file system, tries to copy the source directory and then delete the source.
+ *
+ * @param source the source path
+ * @param destination the destination path
+ * @throws IOException if the moving was unsuccessful
+ */
+ static void moveDirectory(File source, File destination) throws IOException {
+ try {
+ Files.move(source.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE);
+ } catch (IOException e) {
+ try {
+ FileUtils.copyDirectory(source, destination);
+ } catch (IOException ex) {
+ FileUtils.deleteQuietly(destination);
+ throw ex;
+ }
+ FileUtils.deleteDirectory(source);
+ }
+ }
+
+ /**
+ * Sets the projects directory permissions to {@code rwx------}.
+ *
+ * @param path the directory path.
+ * @throws IOException if the action is unsuccessful.
+ */
+ static void setProjectsDirectoryPermissions(File path) throws IOException {
+ Set permissions = new HashSet<>();
+ permissions.add(PosixFilePermission.OWNER_READ);
+ permissions.add(PosixFilePermission.OWNER_WRITE);
+ permissions.add(PosixFilePermission.OWNER_EXECUTE);
+
+ Files.setPosixFilePermissions(path.toPath(), permissions);
+ }
+}
diff --git a/lib/scala/project-manager/src/main/resources/application.conf b/lib/scala/project-manager/src/main/resources/application.conf
index f284777aa64..368c88996ca 100644
--- a/lib/scala/project-manager/src/main/resources/application.conf
+++ b/lib/scala/project-manager/src/main/resources/application.conf
@@ -119,14 +119,12 @@ project-manager {
}
storage {
- projects-root = ${user.home}/enso
projects-root = ${?PROJECTS_ROOT}
- temporary-projects-path = ${project-manager.storage.projects-root}/tmp
- user-projects-path = ${project-manager.storage.projects-root}/projects
- tutorials-path = ${project-manager.storage.projects-root}/tutorials
- tutorials-cache-path = ${project-manager.storage.projects-root}/.tutorials-cache
- project-metadata-directory = ".enso"
- project-metadata-file-name = "project.json"
+ projects-directory = "enso-projects"
+ metadata {
+ project-metadata-directory = ".enso"
+ project-metadata-file-name = "project.json"
+ }
}
timeout {
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 f612851790b..99a844cbc60 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
@@ -21,10 +21,7 @@ import org.enso.projectmanager.infrastructure.languageserver.{
}
import org.enso.projectmanager.infrastructure.log.Slf4jLogging
import org.enso.projectmanager.infrastructure.random.SystemGenerator
-import org.enso.projectmanager.infrastructure.repository.{
- ProjectFileRepository,
- ProjectFileRepositoryFactory
-}
+import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory
import org.enso.projectmanager.infrastructure.time.RealClock
import org.enso.projectmanager.protocol.{
JsonRpcProtocolFactory,
@@ -74,14 +71,6 @@ class MainModule[
lazy val projectRepositoryFactory =
new ProjectFileRepositoryFactory[F](config.storage, clock, fileSystem, gen)
- lazy val projectRepository =
- new ProjectFileRepository[F](
- config.storage,
- clock,
- fileSystem,
- gen
- )
-
val distributionConfiguration = DefaultDistributionConfiguration
val loggingService = Logging.GlobalLoggingService
@@ -149,7 +138,7 @@ class MainModule[
timeoutConfig = config.timeout
)
- lazy val projectsEndpoint = new ProjectsEndpoint(projectRepository)
+ lazy val projectsEndpoint = new ProjectsEndpoint(projectRepositoryFactory)
lazy val server =
new JsonRpcServer(
new JsonRpcProtocolFactory,
diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala
index 79a1be76ee9..f132d2d7a1e 100644
--- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala
+++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/boot/ProjectManager.scala
@@ -21,6 +21,7 @@ import org.enso.projectmanager.boot.configuration.{
MainProcessConfig,
ProjectManagerConfig
}
+import org.enso.projectmanager.infrastructure.migration.ProjectsMigration
import org.enso.projectmanager.protocol.JsonRpcProtocolFactory
import org.enso.version.VersionDescription
import org.slf4j.event.Level
@@ -53,11 +54,11 @@ object ProjectManager extends ZIOAppDefault with LazyLogging {
new JsonRpcProtocolFactory().getProtocol()
)
- val computeThreadPool = new ScheduledThreadPoolExecutor(
+ private val computeThreadPool = new ScheduledThreadPoolExecutor(
java.lang.Runtime.getRuntime.availableProcessors()
)
- val computeExecutionContext: ExecutionContextExecutor =
+ private val computeExecutionContext: ExecutionContextExecutor =
ExecutionContext.fromExecutor(
computeThreadPool,
th => logger.error("An expected error occurred.", th)
@@ -77,6 +78,7 @@ object ProjectManager extends ZIOAppDefault with LazyLogging {
private def mainProcess(
processConfig: MainProcessConfig
): ZIO[ZAny, IOException, Unit] = {
+ ProjectsMigration.migrate(config.storage)
val mainModule =
new MainModule[ZIO[ZAny, +*, +*]](
config,
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 ed8c8b006bd..92afe861541 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
@@ -8,7 +8,7 @@ import org.enso.projectmanager.control.effect.{ErrorChannel, Sync}
import org.enso.projectmanager.infrastructure.file.BlockingFileSystem
import org.enso.projectmanager.infrastructure.random.SystemGenerator
import org.enso.projectmanager.infrastructure.repository.{
- ProjectFileRepository,
+ ProjectFileRepositoryFactory,
ProjectRepository
}
import org.enso.projectmanager.infrastructure.time.RealClock
@@ -47,17 +47,15 @@ object ProjectListCommand {
val clock = new RealClock[F]
val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout)
val gen = new SystemGenerator[F]
- val storageConfig = projectsPath.fold(config.storage)(path =>
- config.storage.copy(userProjectsPath = path)
- )
+ val projectRepositoryFactory = new ProjectFileRepositoryFactory[F](
+ config.storage,
+ clock,
+ fileSystem,
+ gen
+ )
val projectRepository =
- new ProjectFileRepository[F](
- storageConfig,
- clock,
- fileSystem,
- gen
- )
+ projectRepositoryFactory.getProjectRepository(projectsPath)
new ProjectListCommand[F](projectRepository, limitOpt)
}
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 b60327e28b9..af220fd6481 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
@@ -1,9 +1,11 @@
package org.enso.projectmanager.boot
+import org.enso.desktopenvironment.Platform
import org.slf4j.event.Level
-import java.io.File
+import java.io.{File, IOException}
import java.nio.file.Path
+
import scala.concurrent.duration.FiniteDuration
object configuration {
@@ -41,21 +43,37 @@ object configuration {
*/
case class ServerConfig(host: String, port: Int)
- /** A configuration object for properties of project storage.
+ /** A configuration object for metadata storage.
*
- * @param projectsRoot a project root
- * @param userProjectsPath a user project root
- * @param projectMetadataDirectory a directory name containing project
- * metadata
+ * @param projectMetadataDirectory a directory name containing project metadata
* @param projectMetadataFileName a name of project metadata file
*/
- case class StorageConfig(
- projectsRoot: File,
- userProjectsPath: File,
+ case class MetadataStorageConfig(
projectMetadataDirectory: String,
projectMetadataFileName: String
)
+ /** A configuration object for properties of project storage.
+ *
+ * @param projectsRoot overrides user projects root directory
+ * @param projectsDirectory a user projects directory
+ * @param metadata a metadata storage config
+ */
+ case class StorageConfig(
+ projectsRoot: Option[File],
+ projectsDirectory: String,
+ metadata: MetadataStorageConfig
+ ) {
+
+ /** @return a path to the user projects directory. */
+ @throws[IOException]
+ def userProjectsPath: File = {
+ val projectsRootDirectory =
+ projectsRoot.getOrElse(Platform.getDirectories.getDocuments.toFile)
+ new File(projectsRootDirectory, projectsDirectory)
+ }
+ }
+
/** A configuration object for timeout properties.
*
* @param ioTimeout a timeout for IO operations
diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala
index dd8e82fc392..920050a1a58 100644
--- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala
+++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/http/ProjectsEndpoint.scala
@@ -15,7 +15,7 @@ import org.enso.projectmanager.control.core.CovariantFlatMap
import org.enso.projectmanager.control.core.syntax._
import org.enso.projectmanager.control.effect.Exec
import org.enso.projectmanager.infrastructure.repository.{
- ProjectRepository,
+ ProjectRepositoryFactory,
ProjectRepositoryFailure
}
@@ -26,10 +26,13 @@ import scala.util.{Failure, Success}
final class ProjectsEndpoint[
F[+_, +_]: Exec: CovariantFlatMap
-](repo: ProjectRepository[F])
+](projectRepositoryFactory: ProjectRepositoryFactory[F])
extends Endpoint
with LazyLogging {
+ private val projectRepository =
+ projectRepositoryFactory.getProjectRepository(None)
+
/** @inheritdoc */
override def route: Route =
projectsEndpoint
@@ -74,7 +77,7 @@ final class ProjectsEndpoint[
projectId: UUID
): Future[Either[ProjectRepositoryFailure, Option[EnsoProjectArchive]]] =
Exec[F].exec {
- repo
+ projectRepository
.findById(projectId)
.map(projectOpt =>
projectOpt.map(project =>
diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala
index fd3c1a0105b..7c767ea81a8 100644
--- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala
+++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/MetadataFileStorage.scala
@@ -1,9 +1,8 @@
package org.enso.projectmanager.infrastructure.repository
import java.io.File
-
import io.circe.generic.auto._
-import org.enso.projectmanager.boot.configuration.StorageConfig
+import org.enso.projectmanager.boot.configuration.MetadataStorageConfig
import org.enso.projectmanager.control.core.CovariantFlatMap
import org.enso.projectmanager.control.core.syntax._
import org.enso.projectmanager.control.effect.syntax._
@@ -27,7 +26,7 @@ import shapeless.{Coproduct, Inl, Inr}
/** File based implementation of the project metadata storage.
*
* @param directory a project directory
- * @param storageConfig a storage config
+ * @param metadataStorageConfig a metadata storage config
* @param clock a clock
* @param fileSystem a file system abstraction
* @param gen a random generator
@@ -36,7 +35,7 @@ final class MetadataFileStorage[
F[+_, +_]: ErrorChannel: CovariantFlatMap
](
directory: File,
- storageConfig: StorageConfig,
+ metadataStorageConfig: MetadataStorageConfig,
clock: Clock[F],
fileSystem: FileSystem[F],
gen: Generator[F]
@@ -89,8 +88,8 @@ final class MetadataFileStorage[
new File(
project,
new File(
- storageConfig.projectMetadataDirectory,
- storageConfig.projectMetadataFileName
+ metadataStorageConfig.projectMetadataDirectory,
+ metadataStorageConfig.projectMetadataFileName
).toString
)
}
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 e3a79c2184b..08b5bb934bc 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
@@ -4,9 +4,8 @@ import java.io.File
import java.nio.file.Path
import java.nio.file.attribute.FileTime
import java.util.UUID
-
import org.enso.pkg.{Package, PackageManager}
-import org.enso.projectmanager.boot.configuration.StorageConfig
+import org.enso.projectmanager.boot.configuration.MetadataStorageConfig
import org.enso.projectmanager.control.core.{
Applicative,
CovariantFlatMap,
@@ -31,7 +30,7 @@ import org.enso.projectmanager.model.{Project, ProjectMetadata}
/** File based implementation of the project repository.
*
- * @param storageConfig a storage config
+ * @param metadataStorageConfig a metadata storage config
* @param clock a clock
* @param fileSystem a file system abstraction
* @param gen a random generator
@@ -39,7 +38,8 @@ import org.enso.projectmanager.model.{Project, ProjectMetadata}
class ProjectFileRepository[
F[+_, +_]: Sync: ErrorChannel: CovariantFlatMap: Applicative
](
- storageConfig: StorageConfig,
+ projectsPath: File,
+ metadataStorageConfig: MetadataStorageConfig,
clock: Clock[F],
fileSystem: FileSystem[F],
gen: Generator[F]
@@ -60,7 +60,7 @@ class ProjectFileRepository[
/** @inheritdoc */
override def getAll(): F[ProjectRepositoryFailure, List[Project]] = {
fileSystem
- .list(storageConfig.userProjectsPath)
+ .list(projectsPath)
.map(_.filter(_.isDirectory))
.recover { case FileNotFound | NotDirectory =>
Nil
@@ -243,7 +243,7 @@ class ProjectFileRepository[
for {
project <- getProject(projectId)
- primaryPath = new File(storageConfig.userProjectsPath, newName)
+ primaryPath = new File(projectsPath, newName)
finalPath <-
if (isLocationOk(project.path, primaryPath)) {
CovariantFlatMap[F].pure(primaryPath)
@@ -283,7 +283,7 @@ class ProjectFileRepository[
.tailRecM[ProjectRepositoryFailure, Int, File](0) { number =>
val path =
new File(
- storageConfig.userProjectsPath,
+ projectsPath,
moduleName + genSuffix(number)
)
fileSystem
@@ -307,7 +307,7 @@ class ProjectFileRepository[
private def metadataStorage(projectPath: File): MetadataFileStorage[F] =
new MetadataFileStorage[F](
projectPath,
- storageConfig,
+ metadataStorageConfig,
clock,
fileSystem,
gen
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 e3d764373b1..d29f5610561 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
@@ -21,9 +21,14 @@ class ProjectFileRepositoryFactory[
override def getProjectRepository(
projectsDirectory: Option[File]
): ProjectRepository[F] = {
- val config = projectsDirectory.fold(storageConfig)(dir =>
- storageConfig.copy(userProjectsPath = dir)
+ val projectsPath =
+ projectsDirectory.getOrElse(storageConfig.userProjectsPath)
+ new ProjectFileRepository[F](
+ projectsPath,
+ storageConfig.metadata,
+ clock,
+ fileSystem,
+ gen
)
- new ProjectFileRepository[F](config, clock, fileSystem, gen)
}
}
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
new file mode 100644
index 00000000000..02d1651c2f9
--- /dev/null
+++ b/lib/scala/project-manager/src/test/java/org/enso/projectmanager/infrastructure/migration/ProjectsMigrationTest.java
@@ -0,0 +1,124 @@
+package org.enso.projectmanager.infrastructure.migration;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.attribute.PosixFilePermissions;
+import org.apache.commons.io.FileUtils;
+import org.enso.desktopenvironment.Platform;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class ProjectsMigrationTest {
+
+ @Rule public TemporaryFolder tmp = new TemporaryFolder();
+
+ @Test
+ public void moveDirectory() throws IOException {
+ File oldProjectsDir = tmp.newFolder("old-projects");
+ File newProjectsDir = new File(tmp.getRoot(), "new-projects");
+
+ File project1 = createProjectStructure(oldProjectsDir, "Project1");
+ File project2 = createProjectStructure(oldProjectsDir, "Project2");
+
+ Assert.assertTrue(oldProjectsDir.isDirectory());
+ Assert.assertTrue(project1.isDirectory());
+ Assert.assertTrue(project2.isDirectory());
+ Assert.assertFalse(newProjectsDir.isDirectory());
+
+ ProjectsMigration.moveDirectory(oldProjectsDir, newProjectsDir);
+
+ Assert.assertFalse(oldProjectsDir.isDirectory());
+ Assert.assertTrue(newProjectsDir.isDirectory());
+ Assert.assertTrue(new File(newProjectsDir, "Project1").isDirectory());
+ Assert.assertTrue(new File(newProjectsDir, "Project2").isDirectory());
+ }
+
+ @Test
+ public void setProjectDirectoryPermissions() throws IOException {
+ if (!Platform.isWindows()) {
+ File projectsDir = tmp.newFolder("projects");
+ createProjectStructure(projectsDir, "Project1");
+
+ Assert.assertTrue(projectsDir.isDirectory());
+
+ ProjectsMigration.setProjectsDirectoryPermissions(projectsDir);
+
+ var permissions = Files.getPosixFilePermissions(projectsDir.toPath());
+ Assert.assertEquals("rwx------", PosixFilePermissions.toString(permissions));
+ }
+ }
+
+ @Test
+ public void migrateProjectsDirectoryIdempotent() throws IOException {
+ File oldProjectsDir = tmp.newFolder("old-projects");
+ File newProjectsDir = new File(tmp.getRoot(), "new-projects");
+
+ File project1 = createProjectStructure(oldProjectsDir, "Project1");
+ File project2 = createProjectStructure(oldProjectsDir, "Project2");
+
+ Assert.assertTrue(oldProjectsDir.isDirectory());
+ Assert.assertTrue(project1.isDirectory());
+ Assert.assertTrue(project2.isDirectory());
+ Assert.assertFalse(newProjectsDir.isDirectory());
+
+ ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir);
+
+ File newProject1 = new File(newProjectsDir, "Project1");
+ File newProject2 = new File(newProjectsDir, "Project2");
+ Assert.assertFalse(oldProjectsDir.isDirectory());
+ Assert.assertTrue(newProjectsDir.isDirectory());
+ Assert.assertTrue(newProject1.isDirectory());
+ Assert.assertTrue(newProject2.isDirectory());
+
+ ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir);
+
+ Assert.assertFalse(oldProjectsDir.isDirectory());
+ Assert.assertTrue(newProjectsDir.isDirectory());
+ Assert.assertTrue(newProject1.isDirectory());
+ Assert.assertTrue(newProject2.isDirectory());
+ }
+
+ @Test
+ public void migrateProjectsDirectoryCleanupWhenBothExist() throws IOException {
+ File oldProjectsDir = tmp.newFolder("old-projects");
+ File newProjectsDir = new File(tmp.getRoot(), "new-projects");
+
+ File project1 = createProjectStructure(newProjectsDir, "Project1");
+ File project2 = createProjectStructure(newProjectsDir, "Project2");
+
+ Assert.assertTrue(oldProjectsDir.isDirectory());
+ Assert.assertTrue(newProjectsDir.isDirectory());
+ Assert.assertTrue(project1.isDirectory());
+ Assert.assertTrue(project2.isDirectory());
+
+ ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir);
+
+ Assert.assertFalse(oldProjectsDir.isDirectory());
+ Assert.assertTrue(newProjectsDir.isDirectory());
+ Assert.assertTrue(new File(newProjectsDir, "Project1").isDirectory());
+ Assert.assertTrue(new File(newProjectsDir, "Project2").isDirectory());
+ }
+
+ private static File createProjectStructure(File tmp, String name) throws IOException {
+ var projectDir = new File(tmp, name);
+ var srcDir = new File(projectDir, "src");
+ var ensoDir = new File(projectDir, ".enso");
+
+ FileUtils.forceMkdir(srcDir);
+ FileUtils.forceMkdir(ensoDir);
+
+ createNewFile(new File(projectDir, "package.yaml"));
+ createNewFile(new File(srcDir, "Main.enso"));
+
+ return projectDir;
+ }
+
+ private static void createNewFile(File file) throws IOException {
+ if (!file.createNewFile()) {
+ throw new IOException("File '" + file + "' already exists.");
+ }
+ }
+}
diff --git a/lib/scala/project-manager/src/test/resources/application-test.conf b/lib/scala/project-manager/src/test/resources/application-test.conf
index 688e6b1c3b0..fe2ecb616f9 100644
--- a/lib/scala/project-manager/src/test/resources/application-test.conf
+++ b/lib/scala/project-manager/src/test/resources/application-test.conf
@@ -56,13 +56,12 @@ project-manager {
}
storage {
- projects-root = ${user.home}/enso
- projects-root=${?PROJECTS_ROOT}
- project-index-path = ${project-manager.storage.projects-root}/.enso/project-index.json
- temporary-projects-path = ${project-manager.storage.projects-root}/tmp
- user-projects-path = ${project-manager.storage.projects-root}/projects
- tutorials-path = ${project-manager.storage.projects-root}/tutorials
- tutorials-cache-path = ${project-manager.storage.projects-root}/.tutorials-cache
+ projects-root = ${?PROJECTS_ROOT}
+ projects-directory = "enso-projects"
+ metadata {
+ project-metadata-directory = ".enso"
+ project-metadata-file-name = "project.json"
+ }
}
timeout {
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 1332a1018b9..277abb66ad4 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
@@ -108,15 +108,17 @@ class BaseServerSpec extends JsonRpcServerTestKit with BeforeAndAfterAll {
val testDistributionRoot = Files.createTempDirectory(null).toFile
sys.addShutdownHook(FileUtils.deleteQuietly(testDistributionRoot))
- val userProjectDir = new File(testProjectsRoot, "projects")
-
lazy val testStorageConfig = StorageConfig(
- projectsRoot = testProjectsRoot,
- userProjectsPath = userProjectDir,
- projectMetadataDirectory = ".enso",
- projectMetadataFileName = "project.json"
+ projectsRoot = Some(testProjectsRoot),
+ projectsDirectory = "enso-projects",
+ metadata = MetadataStorageConfig(
+ projectMetadataDirectory = ".enso",
+ projectMetadataFileName = "project.json"
+ )
)
+ lazy val userProjectDir = testStorageConfig.userProjectsPath
+
lazy val bootloaderConfig = config.bootloader
lazy val timeoutConfig = config.timeout
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 281a9ee5b1e..8c0d67a6a85 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
@@ -46,7 +46,7 @@ class FileSystemServiceSpec
def metadataFileStorage(directory: File) =
new MetadataFileStorage[ZIO[ZAny, +*, +*]](
directory,
- config.storage,
+ config.storage.metadata,
testClock,
fileSystem,
gen