From 0e6a79020e9d9a7c6b041f718b4b319df1a62f9b Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Wed, 11 Mar 2026 15:22:32 +0000 Subject: [PATCH] update shell --- ...R-04a-project-launcher-window-lifecycle.md | 60 ++++ .../PR-04b-new-project-wizard.md | 64 ++++ docs/studio/pull-requests/README.md | 2 + .../p/studio/compiler/PBSDefinitions.java | 3 + .../studio/compiler/models/FrontendSpec.java | 28 ++ .../compiler/FrontendRegistryService.java | 9 +- prometeu-studio/build.gradle.kts | 1 + .../src/main/java/p/studio/App.java | 12 +- .../studio/projects/KnownProjectsService.java | 98 +++++ .../projects/ProjectCatalogService.java | 138 ++++++- .../projects/ProjectCreationRequest.java | 11 + .../ProjectLanguageCatalogService.java | 47 +++ .../projects/ProjectLanguageTemplate.java | 24 ++ .../p/studio/projects/ProjectReference.java | 7 +- .../java/p/studio/utilities/i18n/I18n.java | 32 ++ .../p/studio/window/NewProjectWizard.java | 339 ++++++++++++++++++ .../p/studio/window/ProjectLauncherView.java | 145 ++++++-- .../java/p/studio/window/StudioRootView.java | 53 --- .../window/StudioWindowCoordinator.java | 128 +++++++ .../p/studio/window/WindowStateService.java | 133 +++++++ .../main/resources/i18n/messages.properties | 31 ++ .../resources/themes/default-prometeu.css | 52 ++- .../projects/KnownProjectsServiceTest.java | 81 +++++ .../projects/ProjectCatalogServiceTest.java | 89 ++++- test-projects/tototo/prometeu.json | 8 + test-projects/umbelivable/prometeu.json | 8 + 26 files changed, 1498 insertions(+), 105 deletions(-) create mode 100644 docs/studio/pull-requests/PR-04a-project-launcher-window-lifecycle.md create mode 100644 docs/studio/pull-requests/PR-04b-new-project-wizard.md create mode 100644 prometeu-studio/src/main/java/p/studio/projects/KnownProjectsService.java create mode 100644 prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java create mode 100644 prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageCatalogService.java create mode 100644 prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageTemplate.java create mode 100644 prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java delete mode 100644 prometeu-studio/src/main/java/p/studio/window/StudioRootView.java create mode 100644 prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java create mode 100644 prometeu-studio/src/main/java/p/studio/window/WindowStateService.java create mode 100644 prometeu-studio/src/test/java/p/studio/projects/KnownProjectsServiceTest.java create mode 100644 test-projects/tototo/prometeu.json create mode 100644 test-projects/umbelivable/prometeu.json diff --git a/docs/studio/pull-requests/PR-04a-project-launcher-window-lifecycle.md b/docs/studio/pull-requests/PR-04a-project-launcher-window-lifecycle.md new file mode 100644 index 00000000..f4b8ab9b --- /dev/null +++ b/docs/studio/pull-requests/PR-04a-project-launcher-window-lifecycle.md @@ -0,0 +1,60 @@ +# PR-04a - Project Launcher Window Lifecycle + +## Briefing + +The current project launcher already exists as a view before the main shell. + +This PR evolves that flow into a separate launcher window so project selection and project editing no longer compete for the same application stage. + +## Objective + +Move the project launcher into its own window lifecycle and define how it coordinates with the main Studio project window. + +## Dependencies + +- [`../specs/1. Studio Shell and Workspace Layout Specification.md`](../specs/1.%20Studio%20Shell%20and%20Workspace%20Layout%20Specification.md) +- [`../specs/2. Studio UI Foundations Specification.md`](../specs/2.%20Studio%20UI%20Foundations%20Specification.md) +- `PR-03 - Project Launcher and Project Entry Shell` + +## Scope + +- introduce a dedicated launcher `Stage` +- open the main Studio shell in a separate project window +- define launcher visibility behavior when a project window opens +- define launcher restoration behavior when a project window closes +- keep event publication aligned with the Studio event model + +## Non-Goals + +- redesigning the launcher content model +- implementing the new project wizard itself +- supporting multiple simultaneous project windows unless needed by the chosen lifecycle + +## Execution Method + +1. Extract launcher-stage creation from the current single-window flow. +2. Open the main Studio shell in its own project window after project selection or creation. +3. Hide the launcher window when a project window opens. +4. Restore or reshow the launcher window when the project window closes. +5. Keep the transition between launcher and project window intentional and easy to follow. +6. Preserve theme and i18n behavior in both windows. + +## Acceptance Criteria + +- the launcher runs in its own window +- the Studio shell runs in a separate project window +- opening or creating a project transitions from launcher to project window cleanly +- closing the project window returns the user to the launcher window +- the flow remains aligned with current project-open and project-created events + +## Validation + +- compile validation of `prometeu-studio` +- smoke validation of launcher window opening +- smoke validation of launcher hide/show behavior when a project window opens or closes + +## Affected Artifacts + +- `prometeu-studio` application entry and stage lifecycle +- launcher and shell composition +- project-open window behavior diff --git a/docs/studio/pull-requests/PR-04b-new-project-wizard.md b/docs/studio/pull-requests/PR-04b-new-project-wizard.md new file mode 100644 index 00000000..b7838120 --- /dev/null +++ b/docs/studio/pull-requests/PR-04b-new-project-wizard.md @@ -0,0 +1,64 @@ +# PR-04b - New Project Wizard + +## Briefing + +The current project launcher can create a project directly from an inline text field. + +This PR replaces that minimal path with a dedicated wizard so project creation becomes a guided flow rather than a single-form action. + +## Objective + +Introduce a new project wizard that guides project creation before opening the Studio shell. + +## Dependencies + +- [`../specs/1. Studio Shell and Workspace Layout Specification.md`](../specs/1.%20Studio%20Shell%20and%20Workspace%20Layout%20Specification.md) +- [`../specs/2. Studio UI Foundations Specification.md`](../specs/2.%20Studio%20UI%20Foundations%20Specification.md) +- `PR-03 - Project Launcher and Project Entry Shell` +- `PR-04a - Project Launcher Window Lifecycle` + +## Scope + +- replace inline project creation with a wizard flow +- support at least: + - project name + - project location + - final confirmation +- create the project through the existing project service layer +- open the project after successful creation + +## Non-Goals + +- full template marketplace or template system +- advanced preset configuration +- remote project provisioning + +## Execution Method + +1. Introduce a `New Project Wizard` surface from the launcher. +2. Split project creation into explicit steps. +3. Validate project name and location before final confirmation. +4. Reuse the existing project creation service instead of duplicating creation logic in the UI. +5. Open the created project after the wizard completes successfully. +6. Keep the wizard aligned with Studio theming and i18n rules. + +## Acceptance Criteria + +- `Create New Project` opens a wizard instead of creating inline immediately +- the wizard supports name, location, and confirmation steps +- invalid project data is blocked before creation +- successful completion creates the project and opens it +- the flow remains consistent with the launcher/project-entry model + +## Validation + +- compile validation of `prometeu-studio` +- smoke validation of wizard opening and step progression +- test or smoke coverage for successful project creation + +## Affected Artifacts + +- launcher UI +- project creation flow +- project service integration +- entry transition from launcher to shell diff --git a/docs/studio/pull-requests/README.md b/docs/studio/pull-requests/README.md index 34a2db1c..311016ff 100644 --- a/docs/studio/pull-requests/README.md +++ b/docs/studio/pull-requests/README.md @@ -41,3 +41,5 @@ The current Studio execution queue is: 1. [`PR-01-studio-event-bus-foundation.md`](./PR-01-studio-event-bus-foundation.md) 2. [`PR-02-studio-shell-components-wave-1.md`](./PR-02-studio-shell-components-wave-1.md) 3. [`PR-03-project-launcher-and-project-entry-shell.md`](./PR-03-project-launcher-and-project-entry-shell.md) +4. [`PR-04a-project-launcher-window-lifecycle.md`](./PR-04a-project-launcher-window-lifecycle.md) +5. [`PR-04b-new-project-wizard.md`](./PR-04b-new-project-wizard.md) diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSDefinitions.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSDefinitions.java index 5e73a613..45b08303 100644 --- a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSDefinitions.java +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSDefinitions.java @@ -3,6 +3,8 @@ package p.studio.compiler; import p.studio.compiler.models.FrontendSpec; import p.studio.utilities.structures.ReadOnlySet; +import java.util.List; + public class PBSDefinitions { public static final FrontendSpec PBS = FrontendSpec .builder() @@ -10,5 +12,6 @@ public class PBSDefinitions { .allowedExtensions(ReadOnlySet.from("pbs", "barrel")) .sourceRoots(ReadOnlySet.from("src")) .entryPointCallableName("frame") + .stdlibVersions(List.of(FrontendSpec.Stdlib.asDefault(1))) .build(); } diff --git a/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/models/FrontendSpec.java b/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/models/FrontendSpec.java index 7a059464..ff317c96 100644 --- a/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/models/FrontendSpec.java +++ b/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/models/FrontendSpec.java @@ -4,6 +4,8 @@ import lombok.Builder; import lombok.Getter; import p.studio.utilities.structures.ReadOnlySet; +import java.util.List; + @Builder @Getter public class FrontendSpec { @@ -13,8 +15,34 @@ public class FrontendSpec { private final boolean caseSensitive; @Builder.Default private final String entryPointCallableName = "main"; + @Builder.Default + private final List stdlibVersions = List.of(); public String toString() { return String.format("FrontendSpec(language=%s, entryPoint=%s)", languageId, entryPointCallableName); } + + public enum StdlibType { + DEFAULT, + STABLE, + EXPERIMENTAL, + } + + public record Stdlib(int version, StdlibType type) { + public static Stdlib asDefault(final int version) { + return new Stdlib(version, StdlibType.DEFAULT); + } + + public static Stdlib asStable(final int version) { + return new Stdlib(version, StdlibType.STABLE); + } + + public static Stdlib of(final int version) { + return asStable(version); + } + + public static Stdlib asExperimental(final int version) { + return new Stdlib(version, StdlibType.EXPERIMENTAL); + } + } } diff --git a/prometeu-compiler/prometeu-frontend-registry/src/main/java/p/studio/compiler/FrontendRegistryService.java b/prometeu-compiler/prometeu-frontend-registry/src/main/java/p/studio/compiler/FrontendRegistryService.java index ee926f91..3c84d122 100644 --- a/prometeu-compiler/prometeu-frontend-registry/src/main/java/p/studio/compiler/FrontendRegistryService.java +++ b/prometeu-compiler/prometeu-frontend-registry/src/main/java/p/studio/compiler/FrontendRegistryService.java @@ -5,6 +5,7 @@ import p.studio.compiler.services.FrontendPhaseService; import p.studio.compiler.services.PBSFrontendPhaseService; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -28,4 +29,10 @@ public class FrontendRegistryService { public static Optional getFrontendPhaseService(final String languageId) { return Optional.ofNullable(FRONTEND_PHASE_SERVICES.get(languageId)); } -} \ No newline at end of file + + public static List listFrontendSpecs() { + return FRONTEND_SPECS.values().stream() + .sorted((left, right) -> left.getLanguageId().compareToIgnoreCase(right.getLanguageId())) + .toList(); + } +} diff --git a/prometeu-studio/build.gradle.kts b/prometeu-studio/build.gradle.kts index b0936a97..2705ae28 100644 --- a/prometeu-studio/build.gradle.kts +++ b/prometeu-studio/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { implementation(project(":prometeu-infra")) implementation(project(":prometeu-compiler:prometeu-compiler-core")) implementation(project(":prometeu-compiler:prometeu-build-pipeline")) + implementation(project(":prometeu-compiler:prometeu-frontend-registry")) implementation(libs.javafx.controls) implementation(libs.javafx.fxml) implementation(libs.richtextfx) diff --git a/prometeu-studio/src/main/java/p/studio/App.java b/prometeu-studio/src/main/java/p/studio/App.java index 16515e64..95cacfd1 100644 --- a/prometeu-studio/src/main/java/p/studio/App.java +++ b/prometeu-studio/src/main/java/p/studio/App.java @@ -1,10 +1,8 @@ package p.studio; import javafx.application.Application; -import javafx.scene.Scene; import javafx.stage.Stage; -import p.studio.utilities.i18n.I18n; -import p.studio.window.StudioRootView; +import p.studio.window.StudioWindowCoordinator; public class App extends Application { @@ -16,13 +14,7 @@ public class App extends Application { @Override public void start(Stage stage) { - var root = new StudioRootView(); - var scene = new Scene(root, 1200, 800); - - scene.getStylesheets().add(Container.theme().getDefaultTheme()); - stage.titleProperty().bind(Container.i18n().bind(I18n.APP_TITLE)); - stage.setScene(scene); - stage.show(); + new StudioWindowCoordinator(stage).showLauncher(); } public static void main(String[] args) { diff --git a/prometeu-studio/src/main/java/p/studio/projects/KnownProjectsService.java b/prometeu-studio/src/main/java/p/studio/projects/KnownProjectsService.java new file mode 100644 index 00000000..8ee7584a --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projects/KnownProjectsService.java @@ -0,0 +1,98 @@ +package p.studio.projects; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public final class KnownProjectsService { + private final Path storageFile; + private final ProjectCatalogService projectCatalogService; + + public KnownProjectsService(Path storageFile, ProjectCatalogService projectCatalogService) { + this.storageFile = Objects.requireNonNull(storageFile, "storageFile").toAbsolutePath().normalize(); + this.projectCatalogService = Objects.requireNonNull(projectCatalogService, "projectCatalogService"); + } + + public List listKnownProjects() { + final boolean storageExists = Files.isRegularFile(storageFile); + final Set knownPaths = readStoredPaths(); + if (!storageExists && knownPaths.isEmpty()) { + final List discovered = projectCatalogService.listProjects(); + writeStoredPaths(discovered.stream().map(ProjectReference::rootPath).toList()); + return discovered; + } + + final List knownProjects = new ArrayList<>(); + boolean mutated = false; + for (Path knownPath : knownPaths) { + if (!Files.isDirectory(knownPath)) { + mutated = true; + continue; + } + knownProjects.add(projectCatalogService.openProject(knownPath)); + } + + knownProjects.sort(Comparator.comparing(ProjectReference::name, String.CASE_INSENSITIVE_ORDER)); + if (mutated) { + writeStoredPaths(knownProjects.stream().map(ProjectReference::rootPath).toList()); + } + return List.copyOf(knownProjects); + } + + public void remember(ProjectReference projectReference) { + final Set knownPaths = readStoredPaths(); + knownPaths.add(normalize(projectReference.rootPath())); + writeStoredPaths(knownPaths); + } + + public void forget(ProjectReference projectReference) { + final Set knownPaths = readStoredPaths(); + knownPaths.remove(normalize(projectReference.rootPath())); + writeStoredPaths(knownPaths); + } + + private Set readStoredPaths() { + if (!Files.isRegularFile(storageFile)) { + return new LinkedHashSet<>(); + } + + try { + final LinkedHashSet paths = new LinkedHashSet<>(); + for (String line : Files.readAllLines(storageFile, StandardCharsets.UTF_8)) { + final String trimmed = line.trim(); + if (trimmed.isBlank()) { + continue; + } + paths.add(normalize(Path.of(trimmed))); + } + return paths; + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private void writeStoredPaths(Iterable paths) { + try { + Files.createDirectories(storageFile.getParent()); + final List lines = new ArrayList<>(); + for (Path path : paths) { + lines.add(normalize(path).toString()); + } + Files.write(storageFile, lines, StandardCharsets.UTF_8); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private Path normalize(Path path) { + return Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java index deeacda5..ee6e7ba9 100644 --- a/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java @@ -1,5 +1,8 @@ package p.studio.projects; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; + import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; @@ -11,6 +14,8 @@ import java.util.Objects; import java.util.stream.Stream; public final class ProjectCatalogService { + private static final String MANIFEST_FILE_NAME = "prometeu.json"; + private static final ObjectMapper MAPPER = new ObjectMapper(); private final Path projectsRoot; public ProjectCatalogService(Path projectsRoot) { @@ -28,8 +33,8 @@ public final class ProjectCatalogService { try (Stream children = Files.list(projectsRoot)) { return children - .filter(Files::isDirectory) - .map(path -> new ProjectReference(path.getFileName().toString(), path)) + .filter(this::isValidProjectRoot) + .map(this::buildReference) .sorted(Comparator.comparing(ProjectReference::name, String.CASE_INSENSITIVE_ORDER)) .toList(); } catch (IOException ioException) { @@ -39,33 +44,146 @@ public final class ProjectCatalogService { public ProjectReference openProject(Path projectPath) { final Path normalized = Objects.requireNonNull(projectPath, "projectPath").toAbsolutePath().normalize(); - if (!Files.isDirectory(normalized)) { - throw new IllegalArgumentException("project directory does not exist: " + normalized); + return openProjectManifest(manifestPath(normalized)); + } + + public ProjectReference openProjectManifest(Path manifestPath) { + final Path normalizedManifest = Objects.requireNonNull(manifestPath, "manifestPath") + .toAbsolutePath() + .normalize(); + if (!normalizedManifest.getFileName().toString().equals(MANIFEST_FILE_NAME)) { + throw new IllegalArgumentException("expected " + MANIFEST_FILE_NAME + ": " + normalizedManifest); } - return new ProjectReference(normalized.getFileName().toString(), normalized); + if (!Files.isRegularFile(normalizedManifest)) { + throw new IllegalArgumentException("project manifest not found: expected " + normalizedManifest); + } + + final Path projectRoot = normalizedManifest.getParent(); + if (projectRoot == null || !Files.isDirectory(projectRoot)) { + throw new IllegalArgumentException("project root does not exist: " + normalizedManifest); + } + + return buildReference(projectRoot); } public ProjectReference createProject(String projectName) { - final String sanitized = sanitizeProjectName(projectName); - if (sanitized.isBlank()) { + return createProject(new ProjectCreationRequest(projectName, projectsRoot, "pbs", 1, "src")); + } + + public ProjectReference createProject(String projectName, Path parentLocation) { + return createProject(new ProjectCreationRequest(projectName, parentLocation, "pbs", 1, "src")); + } + + public ProjectReference createProject(ProjectCreationRequest request) { + final String displayName = Objects.requireNonNull(request.projectName(), "request.projectName").trim(); + final String sanitized = sanitizeProjectName(displayName); + if (displayName.isBlank() || sanitized.isBlank()) { throw new IllegalArgumentException("project name must not be blank"); } + final String languageId = Objects.requireNonNull(request.languageId(), "request.languageId").trim(); + if (languageId.isBlank()) { + throw new IllegalArgumentException("project language must not be blank"); + } + if (request.stdlib() <= 0) { + throw new IllegalArgumentException("project stdlib major must be positive"); + } - final Path projectRoot = projectsRoot.resolve(sanitized).normalize(); + final Path sourceRoot = normalizeSourceRoot(request.sourceRoot()); + final Path normalizedParent = Objects.requireNonNull(request.parentLocation(), "request.parentLocation") + .toAbsolutePath() + .normalize(); + if (Files.exists(normalizedParent) && !Files.isDirectory(normalizedParent)) { + throw new IllegalArgumentException("project location is not a directory: " + normalizedParent); + } + + final Path projectRoot = normalizedParent.resolve(sanitized).normalize(); if (Files.exists(projectRoot)) { throw new IllegalArgumentException("project already exists: " + sanitized); } try { + Files.createDirectories(normalizedParent); Files.createDirectories(projectRoot.resolve(".workspace")); - Files.createDirectories(projectRoot.resolve("src")); + Files.createDirectories(projectRoot.resolve(sourceRoot)); + Files.createDirectories(projectRoot.resolve("assets")); Files.createDirectories(projectRoot.resolve("build")); Files.createDirectories(projectRoot.resolve("cartridge")); + Files.writeString(manifestPath(projectRoot), defaultManifest(displayName, languageId, request.stdlib())); } catch (IOException ioException) { throw new UncheckedIOException(ioException); } - return new ProjectReference(sanitized, projectRoot); + return openProject(projectRoot); + } + + private ProjectReference buildReference(Path projectRoot) { + final Path normalizedRoot = projectRoot.toAbsolutePath().normalize(); + final Path manifestPath = manifestPath(normalizedRoot); + final ProjectManifestSummary manifest; + try { + manifest = MAPPER.readValue(manifestPath.toFile(), ProjectManifestSummary.class); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + + if (manifest.name() == null || manifest.name().isBlank()) { + throw new IllegalArgumentException("project manifest missing name: " + manifestPath); + } + if (manifest.version() == null || manifest.version().isBlank()) { + throw new IllegalArgumentException("project manifest missing version: " + manifestPath); + } + if (manifest.language() == null || manifest.language().isBlank()) { + throw new IllegalArgumentException("project manifest missing language: " + manifestPath); + } + final int stdlibMajor; + try { + stdlibMajor = Integer.parseInt(Objects.requireNonNull(manifest.stdlib(), "manifest.stdlib").trim()); + } catch (RuntimeException runtimeException) { + throw new IllegalArgumentException("project manifest invalid stdlib: " + manifestPath, runtimeException); + } + return new ProjectReference( + manifest.name().trim(), + manifest.version().trim(), + manifest.language().trim(), + stdlibMajor, + normalizedRoot); + } + + private boolean isValidProjectRoot(Path path) { + return Files.isDirectory(path) && Files.isRegularFile(manifestPath(path)); + } + + private Path manifestPath(Path projectRoot) { + return projectRoot.resolve(MANIFEST_FILE_NAME); + } + + private Path normalizeSourceRoot(String sourceRoot) { + final String raw = Objects.requireNonNull(sourceRoot, "sourceRoot").trim(); + if (raw.isBlank()) { + throw new IllegalArgumentException("project source root must not be blank"); + } + final Path normalized = Path.of(raw).normalize(); + if (normalized.isAbsolute() || normalized.startsWith("..")) { + throw new IllegalArgumentException("project source root must be a relative project path"); + } + return normalized; + } + + private String defaultManifest(String projectName, String languageId, int stdlibMajor) { + return """ + { + "name": "%s", + "version": "1.0.0", + "language": "%s", + "stdlib": "%d", + "dependencies": [ + ] + } + """.formatted(projectName, languageId, stdlibMajor); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ProjectManifestSummary(String name, String version, String language, String stdlib) { } private String sanitizeProjectName(String rawName) { diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java new file mode 100644 index 00000000..e912be3b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java @@ -0,0 +1,11 @@ +package p.studio.projects; + +import java.nio.file.Path; + +public record ProjectCreationRequest( + String projectName, + Path parentLocation, + String languageId, + int stdlib, + String sourceRoot) { +} diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageCatalogService.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageCatalogService.java new file mode 100644 index 00000000..394e9cbb --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageCatalogService.java @@ -0,0 +1,47 @@ +package p.studio.projects; + +import p.studio.compiler.FrontendRegistryService; +import p.studio.compiler.models.FrontendSpec; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class ProjectLanguageCatalogService { + public List listLanguageTemplates() { + return FrontendRegistryService.listFrontendSpecs().stream() + .map(this::toTemplate) + .sorted(Comparator.comparing(ProjectLanguageTemplate::languageId, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } + + public ProjectLanguageTemplate getDefaultTemplate() { + return listLanguageTemplates().stream() + .findFirst() + .orElseThrow(() -> new IllegalStateException("no frontend languages are registered")); + } + + private ProjectLanguageTemplate toTemplate(FrontendSpec frontendSpec) { + final List sourceRoots = frontendSpec.getSourceRoots().stream() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + final List stdlibOptions = frontendSpec.getStdlibVersions().stream() + .map(stdlib -> new ProjectLanguageTemplate.StdlibOption(stdlib.version(), stdlib.type())) + .sorted(Comparator.comparingInt(ProjectLanguageTemplate.StdlibOption::version)) + .toList(); + final ProjectLanguageTemplate.StdlibOption defaultStdlib = stdlibOptions.stream() + .filter(option -> option.type() == FrontendSpec.StdlibType.DEFAULT) + .findFirst() + .or(() -> stdlibOptions.stream().findFirst()) + .orElse(new ProjectLanguageTemplate.StdlibOption(1, FrontendSpec.StdlibType.DEFAULT)); + final String defaultSourceRoot = sourceRoots.stream() + .findFirst() + .orElse("src"); + return new ProjectLanguageTemplate( + Objects.requireNonNull(frontendSpec.getLanguageId(), "frontendSpec.languageId"), + stdlibOptions, + defaultStdlib, + sourceRoots, + defaultSourceRoot); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageTemplate.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageTemplate.java new file mode 100644 index 00000000..9d7ccf4c --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectLanguageTemplate.java @@ -0,0 +1,24 @@ +package p.studio.projects; + +import p.studio.compiler.models.FrontendSpec; + +import java.util.List; + +public record ProjectLanguageTemplate( + String languageId, + List stdlibOptions, + StdlibOption defaultStdlib, + List sourceRoots, + String defaultSourceRoot) { + + public record StdlibOption(int version, FrontendSpec.StdlibType type) { + @Override + public String toString() { + return switch (type) { + case DEFAULT -> version + " (default)"; + case STABLE -> version + " (stable)"; + case EXPERIMENTAL -> version + " (experimental)"; + }; + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java index 22772c9a..4f0cb945 100644 --- a/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java @@ -2,7 +2,12 @@ package p.studio.projects; import java.nio.file.Path; -public record ProjectReference(String name, Path rootPath) { +public record ProjectReference( + String name, + String version, + String languageId, + int stdlibVersion, + Path rootPath) { @Override public String toString() { return name; diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index f75f7e33..05721a6c 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -4,6 +4,7 @@ import lombok.Getter; public enum I18n { APP_TITLE("app.title"), + APP_PROJECT_TITLE("app.projectTitle"), MENU_FILE("menu.file"), MENU_FILE_NEWPROJECT("menu.file.newProject"), @@ -20,10 +21,41 @@ public enum I18n { LAUNCHER_SUBTITLE("launcher.subtitle"), LAUNCHER_EXISTING_PROJECTS("launcher.existingProjects"), LAUNCHER_OPEN_PROJECT("launcher.openProject"), + LAUNCHER_ADD_PROJECT("launcher.addProject"), + LAUNCHER_FORGET_PROJECT("launcher.forgetProject"), LAUNCHER_CREATE_PROJECT("launcher.createProject"), LAUNCHER_PROJECT_NAME_PROMPT("launcher.projectNamePrompt"), LAUNCHER_CREATE_BUTTON("launcher.createButton"), + WIZARD_TITLE("wizard.title"), + WIZARD_BACK("wizard.back"), + WIZARD_NEXT("wizard.next"), + WIZARD_CREATE("wizard.create"), + WIZARD_CANCEL("wizard.cancel"), + WIZARD_BROWSE("wizard.browse"), + WIZARD_STEP_NAME_TITLE("wizard.step.name.title"), + WIZARD_STEP_NAME_DESCRIPTION("wizard.step.name.description"), + WIZARD_STEP_LANGUAGE_TITLE("wizard.step.language.title"), + WIZARD_STEP_LANGUAGE_DESCRIPTION("wizard.step.language.description"), + WIZARD_STEP_LOCATION_TITLE("wizard.step.location.title"), + WIZARD_STEP_LOCATION_DESCRIPTION("wizard.step.location.description"), + WIZARD_STEP_CONFIRM_TITLE("wizard.step.confirm.title"), + WIZARD_STEP_CONFIRM_DESCRIPTION("wizard.step.confirm.description"), + WIZARD_LANGUAGE_LABEL("wizard.language.label"), + WIZARD_STDLIB_LABEL("wizard.stdlib.label"), + WIZARD_SOURCE_ROOT_LABEL("wizard.sourceRoot.label"), + WIZARD_CONFIRM_NAME("wizard.confirm.name"), + WIZARD_CONFIRM_LANGUAGE("wizard.confirm.language"), + WIZARD_CONFIRM_STDLIB("wizard.confirm.stdlib"), + WIZARD_CONFIRM_SOURCE_ROOT("wizard.confirm.sourceRoot"), + WIZARD_CONFIRM_LOCATION("wizard.confirm.location"), + WIZARD_CONFIRM_ROOT("wizard.confirm.root"), + WIZARD_ERROR_NAME_REQUIRED("wizard.error.nameRequired"), + WIZARD_ERROR_LANGUAGE_REQUIRED("wizard.error.languageRequired"), + WIZARD_ERROR_STDLIB_REQUIRED("wizard.error.stdlibRequired"), + WIZARD_ERROR_SOURCE_ROOT_REQUIRED("wizard.error.sourceRootRequired"), + WIZARD_ERROR_LOCATION_REQUIRED("wizard.error.locationRequired"), + TOOLBAR_PLAY("toolbar.play"), TOOLBAR_STOP("toolbar.stop"), TOOLBAR_EXPORT("toolbar.export"), diff --git a/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java b/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java new file mode 100644 index 00000000..9dea6b68 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java @@ -0,0 +1,339 @@ +package p.studio.window; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.DirectoryChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; +import p.studio.Container; +import p.studio.projects.ProjectCatalogService; +import p.studio.projects.ProjectCreationRequest; +import p.studio.projects.ProjectLanguageCatalogService; +import p.studio.projects.ProjectLanguageTemplate; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; + +import java.io.File; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +public final class NewProjectWizard { + private final ProjectCatalogService projectCatalogService; + private final ProjectLanguageCatalogService projectLanguageCatalogService; + private final Stage stage; + private final AtomicReference result = new AtomicReference<>(); + private final Label stepTitle = new Label(); + private final Label stepDescription = new Label(); + private final VBox stepBody = new VBox(12); + private final Label feedbackLabel = new Label(); + private final Button backButton = new Button(); + private final Button nextButton = new Button(); + private final Button createButton = new Button(); + private final TextField projectNameField = new TextField(); + private final TextField locationField = new TextField(); + private final ComboBox languageCombo = new ComboBox<>(); + private final ComboBox stdlibCombo = new ComboBox<>(); + private final ComboBox sourceRootCombo = new ComboBox<>(); + private final Map languageTemplatesById = new LinkedHashMap<>(); + private int stepIndex = 0; + + private NewProjectWizard(Window owner, ProjectCatalogService projectCatalogService) { + this.projectCatalogService = Objects.requireNonNull(projectCatalogService, "projectCatalogService"); + this.projectLanguageCatalogService = new ProjectLanguageCatalogService(); + this.stage = new Stage(); + stage.initOwner(owner); + stage.initModality(Modality.WINDOW_MODAL); + stage.setTitle(Container.i18n().text(I18n.WIZARD_TITLE)); + stage.setScene(new Scene(buildRoot(), 700, 420)); + stage.getScene().getStylesheets().add(Container.theme().getDefaultTheme()); + + projectNameField.promptTextProperty().bind(Container.i18n().bind(I18n.LAUNCHER_PROJECT_NAME_PROMPT)); + locationField.setText(projectCatalogService.projectsRoot().toString()); + initializeLanguageTemplates(); + + renderStep(); + } + + public static Optional showAndWait(Window owner, ProjectCatalogService projectCatalogService) { + final NewProjectWizard wizard = new NewProjectWizard(owner, projectCatalogService); + wizard.stage.showAndWait(); + return Optional.ofNullable(wizard.result.get()); + } + + private VBox buildRoot() { + stepTitle.getStyleClass().add("studio-launcher-section-title"); + stepDescription.getStyleClass().add("studio-launcher-subtitle"); + feedbackLabel.getStyleClass().add("studio-launcher-feedback"); + feedbackLabel.setWrapText(true); + + backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK)); + backButton.setOnAction(ignored -> goBack()); + + nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT)); + nextButton.setOnAction(ignored -> goNext()); + + createButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CREATE)); + createButton.setOnAction(ignored -> finishCreate()); + + final Button cancelButton = new Button(); + cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); + cancelButton.setOnAction(ignored -> stage.close()); + + final HBox actions = new HBox(12, backButton, nextButton, createButton, cancelButton); + actions.setAlignment(Pos.CENTER_RIGHT); + + final VBox root = new VBox(16, stepTitle, stepDescription, stepBody, feedbackLabel, actions); + root.setPadding(new Insets(24)); + VBox.setVgrow(stepBody, Priority.ALWAYS); + return root; + } + + private void renderStep() { + feedbackLabel.setText(""); + backButton.setDisable(stepIndex == 0); + nextButton.setVisible(stepIndex < 3); + nextButton.setManaged(stepIndex < 3); + createButton.setVisible(stepIndex == 3); + createButton.setManaged(stepIndex == 3); + + switch (stepIndex) { + case 0 -> renderNameStep(); + case 1 -> renderLanguageStep(); + case 2 -> renderLocationStep(); + case 3 -> renderConfirmStep(); + default -> throw new IllegalStateException("unknown wizard step: " + stepIndex); + } + } + + private void renderNameStep() { + stepTitle.textProperty().unbind(); + stepDescription.textProperty().unbind(); + stepTitle.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_NAME_TITLE)); + stepDescription.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_NAME_DESCRIPTION)); + stepBody.getChildren().setAll(projectNameField); + } + + private void renderLanguageStep() { + stepTitle.textProperty().unbind(); + stepDescription.textProperty().unbind(); + stepTitle.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_LANGUAGE_TITLE)); + stepDescription.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_LANGUAGE_DESCRIPTION)); + + final Label languageLabel = new Label(Container.i18n().text(I18n.WIZARD_LANGUAGE_LABEL)); + final Label stdlibLabel = new Label(Container.i18n().text(I18n.WIZARD_STDLIB_LABEL)); + final Label sourceRootLabel = new Label(Container.i18n().text(I18n.WIZARD_SOURCE_ROOT_LABEL)); + + languageCombo.setMaxWidth(Double.MAX_VALUE); + stdlibCombo.setMaxWidth(Double.MAX_VALUE); + sourceRootCombo.setMaxWidth(Double.MAX_VALUE); + + final VBox languageBox = new VBox(6, languageLabel, languageCombo); + final VBox stdlibBox = new VBox(6, stdlibLabel, stdlibCombo); + final VBox sourceRootBox = new VBox(6, sourceRootLabel, sourceRootCombo); + stepBody.getChildren().setAll(languageBox, stdlibBox, sourceRootBox); + } + + private void renderLocationStep() { + stepTitle.textProperty().unbind(); + stepDescription.textProperty().unbind(); + stepTitle.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_LOCATION_TITLE)); + stepDescription.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_LOCATION_DESCRIPTION)); + + final Button browseButton = new Button(); + browseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE)); + browseButton.setOnAction(ignored -> browseForLocation()); + + final HBox row = new HBox(12, locationField, browseButton); + HBox.setHgrow(locationField, Priority.ALWAYS); + row.setAlignment(Pos.CENTER_LEFT); + stepBody.getChildren().setAll(row); + } + + private void renderConfirmStep() { + stepTitle.textProperty().unbind(); + stepDescription.textProperty().unbind(); + stepTitle.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_CONFIRM_TITLE)); + stepDescription.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_CONFIRM_DESCRIPTION)); + + final Label projectName = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_NAME, sanitizedName())); + final Label projectLanguage = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_LANGUAGE, selectedLanguageId())); + final Label projectStdlib = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_STDLIB, selectedStdlibLabel())); + final Label projectSourceRoot = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_SOURCE_ROOT, selectedSourceRoot())); + final Label projectLocation = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_LOCATION, locationField.getText().trim())); + final Label projectRoot = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_ROOT, resolvedProjectRoot().toString())); + + stepBody.getChildren().setAll( + projectName, + projectLanguage, + projectStdlib, + projectSourceRoot, + projectLocation, + projectRoot); + } + + private void goBack() { + if (stepIndex == 0) { + return; + } + stepIndex -= 1; + renderStep(); + } + + private void goNext() { + if (!validateCurrentStep()) { + return; + } + stepIndex += 1; + renderStep(); + } + + private boolean validateCurrentStep() { + return switch (stepIndex) { + case 0 -> validateName(); + case 1 -> validateLanguageSettings(); + case 2 -> validateLocation(); + default -> true; + }; + } + + private boolean validateName() { + if (sanitizedName().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.WIZARD_ERROR_NAME_REQUIRED)); + return false; + } + return true; + } + + private boolean validateLanguageSettings() { + if (selectedLanguageId().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.WIZARD_ERROR_LANGUAGE_REQUIRED)); + return false; + } + if (selectedSourceRoot().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.WIZARD_ERROR_SOURCE_ROOT_REQUIRED)); + return false; + } + if (selectedStdlib() == null) { + feedbackLabel.setText(Container.i18n().text(I18n.WIZARD_ERROR_STDLIB_REQUIRED)); + return false; + } + return true; + } + + private boolean validateLocation() { + final String rawLocation = locationField.getText().trim(); + if (rawLocation.isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.WIZARD_ERROR_LOCATION_REQUIRED)); + return false; + } + try { + Path.of(rawLocation); + } catch (RuntimeException runtimeException) { + feedbackLabel.setText(runtimeException.getMessage()); + return false; + } + return true; + } + + private void finishCreate() { + if (!validateName() || !validateLanguageSettings() || !validateLocation()) { + return; + } + + try { + result.set(projectCatalogService.createProject(new ProjectCreationRequest( + projectNameField.getText(), + Path.of(locationField.getText().trim()), + selectedLanguageId(), + selectedStdlib().version(), + selectedSourceRoot()))); + stage.close(); + } catch (IllegalArgumentException exception) { + feedbackLabel.setText(exception.getMessage()); + } + } + + private void browseForLocation() { + final DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(Container.i18n().text(I18n.WIZARD_BROWSE)); + + final String currentText = locationField.getText().trim(); + if (!currentText.isBlank()) { + final File currentDirectory = Path.of(currentText).toFile(); + if (currentDirectory.isDirectory()) { + chooser.setInitialDirectory(currentDirectory); + } + } + + final File selected = chooser.showDialog(stage); + if (selected != null) { + locationField.setText(selected.toPath().toAbsolutePath().normalize().toString()); + } + } + + private String sanitizedName() { + return projectNameField.getText() == null ? "" : projectNameField.getText().trim(); + } + + private void initializeLanguageTemplates() { + final List templates = projectLanguageCatalogService.listLanguageTemplates(); + for (ProjectLanguageTemplate template : templates) { + languageTemplatesById.put(template.languageId(), template); + } + languageCombo.getItems().setAll(languageTemplatesById.keySet()); + languageCombo.getSelectionModel().selectedItemProperty().addListener((ignored, oldValue, newValue) -> applyLanguageTemplate(newValue)); + if (!templates.isEmpty()) { + languageCombo.getSelectionModel().select(projectLanguageCatalogService.getDefaultTemplate().languageId()); + } + } + + private void applyLanguageTemplate(String languageId) { + final ProjectLanguageTemplate template = languageTemplatesById.get(languageId); + if (template == null) { + stdlibCombo.getItems().clear(); + stdlibCombo.getSelectionModel().clearSelection(); + sourceRootCombo.getItems().clear(); + sourceRootCombo.getSelectionModel().clearSelection(); + return; + } + stdlibCombo.getItems().setAll(template.stdlibOptions()); + stdlibCombo.getSelectionModel().select(template.defaultStdlib()); + sourceRootCombo.getItems().setAll(template.sourceRoots()); + sourceRootCombo.getSelectionModel().select(template.defaultSourceRoot()); + } + + private String selectedLanguageId() { + return languageCombo.getValue() == null ? "" : languageCombo.getValue().trim(); + } + + private String selectedSourceRoot() { + return sourceRootCombo.getValue() == null ? "" : sourceRootCombo.getValue().trim(); + } + + private ProjectLanguageTemplate.StdlibOption selectedStdlib() { + return stdlibCombo.getValue(); + } + + private String selectedStdlibLabel() { + final ProjectLanguageTemplate.StdlibOption selectedStdlib = selectedStdlib(); + return selectedStdlib == null ? "" : selectedStdlib.toString(); + } + + private Path resolvedProjectRoot() { + return Path.of(locationField.getText().trim()).toAbsolutePath().normalize().resolve(sanitizedName()); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java b/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java index a121580b..cc8f1929 100644 --- a/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java +++ b/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java @@ -5,43 +5,65 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.Label; +import javafx.scene.control.ListCell; import javafx.scene.control.ListView; -import javafx.scene.control.TextField; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; +import javafx.scene.input.MouseButton; +import javafx.stage.FileChooser; import p.studio.Container; +import p.studio.projects.KnownProjectsService; import p.studio.projects.ProjectCatalogService; import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; +import java.io.File; +import java.nio.file.Path; import java.util.Objects; import java.util.function.Consumer; public final class ProjectLauncherView extends BorderPane { + private final KnownProjectsService knownProjectsService; private final ProjectCatalogService projectCatalogService; private final Consumer onOpenProject; + private final Consumer onAddProject; private final Consumer onCreateProject; + private final Consumer onForgetProject; private final ListView projectList = new ListView<>(); - private final TextField projectNameField = new TextField(); private final Label feedbackLabel = new Label(); public ProjectLauncherView( + KnownProjectsService knownProjectsService, ProjectCatalogService projectCatalogService, Consumer onOpenProject, - Consumer onCreateProject) { + Consumer onAddProject, + Consumer onCreateProject, + Consumer onForgetProject) { + this.knownProjectsService = Objects.requireNonNull(knownProjectsService, "knownProjectsService"); this.projectCatalogService = Objects.requireNonNull(projectCatalogService, "projectCatalogService"); this.onOpenProject = Objects.requireNonNull(onOpenProject, "onOpenProject"); + this.onAddProject = Objects.requireNonNull(onAddProject, "onAddProject"); this.onCreateProject = Objects.requireNonNull(onCreateProject, "onCreateProject"); + this.onForgetProject = Objects.requireNonNull(onForgetProject, "onForgetProject"); getStyleClass().add("studio-project-launcher"); - setPadding(new Insets(24)); - setCenter(buildContent()); + setPadding(new Insets(16)); + setCenter(buildCenteredContent()); reloadProjects(); } + private StackPane buildCenteredContent() { + final VBox content = buildContent(); + final StackPane wrapper = new StackPane(content); + wrapper.setPadding(new Insets(8, 12, 8, 12)); + StackPane.setAlignment(content, Pos.CENTER); + return wrapper; + } + private VBox buildContent() { final Label title = new Label(); title.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_TITLE)); @@ -56,6 +78,12 @@ public final class ProjectLauncherView extends BorderPane { listTitle.getStyleClass().add("studio-launcher-section-title"); projectList.getStyleClass().add("studio-project-list"); + projectList.setCellFactory(ignored -> new ProjectReferenceListCell()); + projectList.setOnMouseClicked(event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + openSelectedProject(); + } + }); VBox.setVgrow(projectList, Priority.ALWAYS); final Button openButton = new Button(); @@ -63,27 +91,33 @@ public final class ProjectLauncherView extends BorderPane { openButton.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull()); openButton.setOnAction(ignored -> openSelectedProject()); - final HBox openRow = new HBox(openButton); + final Button addButton = new Button(); + addButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_ADD_PROJECT)); + addButton.setOnAction(ignored -> addExistingProject()); + + final Button forgetButton = new Button(); + forgetButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_FORGET_PROJECT)); + forgetButton.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull()); + forgetButton.setOnAction(ignored -> forgetSelectedProject()); + + final HBox openRow = new HBox(10, openButton, addButton, forgetButton); openRow.setAlignment(Pos.CENTER_LEFT); final Label createTitle = new Label(); createTitle.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_PROJECT)); createTitle.getStyleClass().add("studio-launcher-section-title"); - projectNameField.promptTextProperty().bind(Container.i18n().bind(I18n.LAUNCHER_PROJECT_NAME_PROMPT)); - HBox.setHgrow(projectNameField, Priority.ALWAYS); - final Button createButton = new Button(); createButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_BUTTON)); - createButton.setOnAction(ignored -> createProject()); + createButton.setOnAction(ignored -> openCreateWizard()); - final HBox createRow = new HBox(12, projectNameField, createButton); + final HBox createRow = new HBox(10, createButton); createRow.setAlignment(Pos.CENTER_LEFT); feedbackLabel.getStyleClass().add("studio-launcher-feedback"); feedbackLabel.setWrapText(true); - final VBox content = new VBox(16, + final VBox content = new VBox(12, title, subtitle, listTitle, @@ -92,12 +126,12 @@ public final class ProjectLauncherView extends BorderPane { createTitle, createRow, feedbackLabel); - content.setMaxWidth(720); + content.setMaxWidth(520); return content; } - private void reloadProjects() { - projectList.setItems(FXCollections.observableArrayList(projectCatalogService.listProjects())); + public void reloadProjects() { + projectList.setItems(FXCollections.observableArrayList(knownProjectsService.listKnownProjects())); if (!projectList.getItems().isEmpty()) { projectList.getSelectionModel().selectFirst(); } @@ -115,18 +149,87 @@ public final class ProjectLauncherView extends BorderPane { onOpenProject.accept(selected); } - private void createProject() { - try { - final ProjectReference created = projectCatalogService.createProject(projectNameField.getText()); - projectNameField.clear(); + private void forgetSelectedProject() { + final ProjectReference selected = projectList.getSelectionModel().getSelectedItem(); + if (selected == null) { + feedbackLabel.setText(""); + return; + } + + feedbackLabel.textProperty().unbind(); + feedbackLabel.setText(""); + onForgetProject.accept(selected); + } + + private void openCreateWizard() { + NewProjectWizard.showAndWait(getScene().getWindow(), projectCatalogService).ifPresent(created -> { reloadProjects(); projectList.getSelectionModel().select(created); feedbackLabel.textProperty().unbind(); feedbackLabel.setText(""); onCreateProject.accept(created); - } catch (IllegalArgumentException illegalArgumentException) { + }); + } + + private void addExistingProject() { + final FileChooser chooser = new FileChooser(); + chooser.setTitle(Container.i18n().text(I18n.LAUNCHER_ADD_PROJECT)); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Prometeu Project", "prometeu.json")); + chooser.setInitialFileName("prometeu.json"); + final File initialDirectory = projectCatalogService.projectsRoot().toFile(); + if (initialDirectory.isDirectory()) { + chooser.setInitialDirectory(initialDirectory); + } + + final File selected = chooser.showOpenDialog(getScene().getWindow()); + if (selected == null) { + return; + } + + try { + final ProjectReference project = projectCatalogService.openProjectManifest(Path.of(selected.toURI())); + onAddProject.accept(project); + reloadProjects(); + projectList.getSelectionModel().select(project); feedbackLabel.textProperty().unbind(); - feedbackLabel.setText(illegalArgumentException.getMessage()); + feedbackLabel.setText(""); + } catch (IllegalArgumentException exception) { + feedbackLabel.textProperty().unbind(); + feedbackLabel.setText(exception.getMessage()); + } + } + + private static final class ProjectReferenceListCell extends ListCell { + private final Label nameLabel = new Label(); + private final Label metadataLabel = new Label(); + private final Label pathLabel = new Label(); + private final VBox content = new VBox(3, nameLabel, metadataLabel, pathLabel); + + private ProjectReferenceListCell() { + nameLabel.getStyleClass().add("studio-project-list-name"); + metadataLabel.getStyleClass().add("studio-project-list-metadata"); + pathLabel.getStyleClass().add("studio-project-list-path"); + metadataLabel.setWrapText(true); + pathLabel.setWrapText(true); + } + + @Override + protected void updateItem(ProjectReference item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + return; + } + + nameLabel.setText(item.name()); + metadataLabel.setText("v%s | %s | stdlib %d".formatted( + item.version(), + item.languageId(), + item.stdlibVersion())); + pathLabel.setText(item.rootPath().toString()); + setText(null); + setGraphic(content); } } } diff --git a/prometeu-studio/src/main/java/p/studio/window/StudioRootView.java b/prometeu-studio/src/main/java/p/studio/window/StudioRootView.java deleted file mode 100644 index a8b96909..00000000 --- a/prometeu-studio/src/main/java/p/studio/window/StudioRootView.java +++ /dev/null @@ -1,53 +0,0 @@ -package p.studio.window; - -import javafx.scene.layout.StackPane; -import p.studio.Container; -import p.studio.events.StudioProjectCreatedEvent; -import p.studio.events.StudioProjectOpenedEvent; -import p.studio.projects.ProjectCatalogService; -import p.studio.projects.ProjectReference; - -import java.nio.file.Path; - -public final class StudioRootView extends StackPane { - private final ProjectCatalogService projectCatalogService = new ProjectCatalogService(resolveDefaultProjectsRoot()); - - public StudioRootView() { - showLauncher(); - } - - private void showLauncher() { - getChildren().setAll(new ProjectLauncherView( - projectCatalogService, - this::openProject, - this::createProject)); - } - - private void openProject(ProjectReference projectReference) { - final ProjectReference opened = projectCatalogService.openProject(projectReference.rootPath()); - Container.events().publish(new StudioProjectOpenedEvent(opened)); - showShell(opened); - } - - private void createProject(ProjectReference projectReference) { - Container.events().publish(new StudioProjectCreatedEvent(projectReference)); - Container.events().publish(new StudioProjectOpenedEvent(projectReference)); - showShell(projectReference); - } - - private void showShell(ProjectReference projectReference) { - getChildren().setAll(new MainView(projectReference)); - } - - private Path resolveDefaultProjectsRoot() { - Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); - while (cursor != null) { - if (cursor.resolve("settings.gradle.kts").toFile().exists() - && cursor.resolve("test-projects").toFile().exists()) { - return cursor.resolve("test-projects"); - } - cursor = cursor.getParent(); - } - return Path.of("test-projects").toAbsolutePath().normalize(); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java new file mode 100644 index 00000000..ed5c1c17 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java @@ -0,0 +1,128 @@ +package p.studio.window; + +import javafx.scene.Scene; +import javafx.stage.Stage; +import p.studio.Container; +import p.studio.events.StudioProjectCreatedEvent; +import p.studio.events.StudioProjectOpenedEvent; +import p.studio.projects.KnownProjectsService; +import p.studio.projects.ProjectCatalogService; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; + +import java.nio.file.Path; +import java.util.Objects; + +public final class StudioWindowCoordinator { + private static final double LAUNCHER_WIDTH = 760; + private static final double LAUNCHER_HEIGHT = 750; + private static final double PROJECT_WIDTH = 1280; + private static final double PROJECT_HEIGHT = 840; + + private final Stage launcherStage; + private final ProjectCatalogService projectCatalogService; + private final KnownProjectsService knownProjectsService; + private final WindowStateService windowStateService; + private final ProjectLauncherView launcherView; + + public StudioWindowCoordinator(Stage launcherStage) { + this.launcherStage = Objects.requireNonNull(launcherStage, "launcherStage"); + this.projectCatalogService = new ProjectCatalogService(resolveDefaultProjectsRoot()); + this.knownProjectsService = new KnownProjectsService(resolveKnownProjectsStorage(), projectCatalogService); + this.windowStateService = new WindowStateService(resolveWindowStateStorage()); + this.launcherView = new ProjectLauncherView( + knownProjectsService, + projectCatalogService, + this::openProject, + this::addProject, + this::createProject, + this::forgetProject); + } + + public void showLauncher() { + final Scene scene = new Scene(launcherView, LAUNCHER_WIDTH, LAUNCHER_HEIGHT); + scene.getStylesheets().add(Container.theme().getDefaultTheme()); + + launcherStage.titleProperty().bind(Container.i18n().bind(I18n.APP_TITLE)); + launcherStage.setScene(scene); + windowStateService.installLauncherState(launcherStage, LAUNCHER_WIDTH, LAUNCHER_HEIGHT); + launcherStage.setResizable(false); + launcherStage.setWidth(LAUNCHER_WIDTH); + launcherStage.setHeight(LAUNCHER_HEIGHT); + launcherStage.setMinWidth(LAUNCHER_WIDTH); + launcherStage.setMaxWidth(LAUNCHER_WIDTH); + launcherStage.setMinHeight(LAUNCHER_HEIGHT); + launcherStage.setMaxHeight(LAUNCHER_HEIGHT); + launcherStage.show(); + launcherStage.centerOnScreen(); + launcherStage.toFront(); + } + + private void openProject(ProjectReference projectReference) { + final ProjectReference opened = projectCatalogService.openProject(projectReference.rootPath()); + knownProjectsService.remember(opened); + Container.events().publish(new StudioProjectOpenedEvent(opened)); + openProjectWindow(opened); + } + + private void createProject(ProjectReference projectReference) { + knownProjectsService.remember(projectReference); + Container.events().publish(new StudioProjectCreatedEvent(projectReference)); + Container.events().publish(new StudioProjectOpenedEvent(projectReference)); + openProjectWindow(projectReference); + } + + private void addProject(ProjectReference projectReference) { + knownProjectsService.remember(projectReference); + launcherView.reloadProjects(); + } + + private void forgetProject(ProjectReference projectReference) { + knownProjectsService.forget(projectReference); + launcherView.reloadProjects(); + } + + private void openProjectWindow(ProjectReference projectReference) { + final Stage projectStage = new Stage(); + final Scene scene = new Scene(new MainView(projectReference), PROJECT_WIDTH, PROJECT_HEIGHT); + scene.getStylesheets().add(Container.theme().getDefaultTheme()); + + projectStage.setTitle(Container.i18n().format(I18n.APP_PROJECT_TITLE, projectReference.name())); + projectStage.setScene(scene); + windowStateService.installProjectShellState(projectStage, projectReference, PROJECT_WIDTH, PROJECT_HEIGHT); + projectStage.setOnHidden(ignored -> { + launcherView.reloadProjects(); + launcherStage.show(); + launcherStage.centerOnScreen(); + launcherStage.toFront(); + }); + + launcherStage.hide(); + projectStage.show(); + projectStage.toFront(); + } + + private Path resolveDefaultProjectsRoot() { + Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + while (cursor != null) { + if (cursor.resolve("settings.gradle.kts").toFile().exists() + && cursor.resolve("test-projects").toFile().exists()) { + return cursor.resolve("test-projects"); + } + cursor = cursor.getParent(); + } + return Path.of("test-projects").toAbsolutePath().normalize(); + } + + private Path resolveKnownProjectsStorage() { + return Path.of(System.getProperty("user.home"), ".prometeu-studio", "known-projects.txt") + .toAbsolutePath() + .normalize(); + } + + private Path resolveWindowStateStorage() { + return Path.of(System.getProperty("user.home"), ".prometeu-studio", "window-state.json") + .toAbsolutePath() + .normalize(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/window/WindowStateService.java b/prometeu-studio/src/main/java/p/studio/window/WindowStateService.java new file mode 100644 index 00000000..18b9e440 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/window/WindowStateService.java @@ -0,0 +1,133 @@ +package p.studio.window; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import javafx.application.Platform; +import javafx.stage.Stage; +import p.studio.projects.ProjectReference; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class WindowStateService { + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + + private final Path storageFile; + + public WindowStateService(Path storageFile) { + this.storageFile = Objects.requireNonNull(storageFile, "storageFile").toAbsolutePath().normalize(); + } + + public void installLauncherState(Stage stage, double defaultWidth, double defaultHeight) { + final WindowStateRecord launcherState = loadState().launcher(); + stage.setWidth(defaultWidth); + stage.setHeight(defaultHeight); + if (launcherState != null && launcherState.maximized()) { + Platform.runLater(() -> stage.setMaximized(false)); + } + + stage.maximizedProperty().addListener((ignored, oldValue, newValue) -> { + if (Boolean.TRUE.equals(newValue)) { + Platform.runLater(() -> stage.setMaximized(false)); + } + }); + stage.fullScreenProperty().addListener((ignored, oldValue, newValue) -> { + if (Boolean.TRUE.equals(newValue)) { + Platform.runLater(() -> stage.setFullScreen(false)); + } + }); + } + + public void installProjectShellState( + Stage stage, + ProjectReference projectReference, + double defaultWidth, + double defaultHeight) { + final String projectKey = normalizeProjectKey(projectReference); + final WindowStateRecord state = loadState().projects().get(projectKey); + applyState(stage, state, defaultWidth, defaultHeight); + + stage.xProperty().addListener((ignored, oldValue, newValue) -> saveProjectState(projectKey, stage)); + stage.yProperty().addListener((ignored, oldValue, newValue) -> saveProjectState(projectKey, stage)); + stage.widthProperty().addListener((ignored, oldValue, newValue) -> saveProjectState(projectKey, stage)); + stage.heightProperty().addListener((ignored, oldValue, newValue) -> saveProjectState(projectKey, stage)); + stage.maximizedProperty().addListener((ignored, oldValue, newValue) -> saveProjectState(projectKey, stage)); + } + + private void applyState(Stage stage, WindowStateRecord state, double defaultWidth, double defaultHeight) { + stage.setWidth(state != null && state.width() > 0 ? state.width() : defaultWidth); + stage.setHeight(state != null && state.height() > 0 ? state.height() : defaultHeight); + if (state != null && !Double.isNaN(state.x())) { + stage.setX(state.x()); + } + if (state != null && !Double.isNaN(state.y())) { + stage.setY(state.y()); + } + if (state != null && state.maximized()) { + stage.setMaximized(true); + } + } + + private void saveProjectState(String projectKey, Stage stage) { + final WindowStateFile file = loadState(); + file.projects().put(projectKey, new WindowStateRecord( + stage.getX(), + stage.getY(), + stage.getWidth(), + stage.getHeight(), + stage.isMaximized())); + storeState(file); + } + + private WindowStateFile loadState() { + if (!Files.isRegularFile(storageFile)) { + return WindowStateFile.empty(); + } + + try { + final WindowStateFile loaded = MAPPER.readValue(storageFile.toFile(), WindowStateFile.class); + return loaded == null ? WindowStateFile.empty() : loaded.normalize(); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private void storeState(WindowStateFile file) { + try { + Files.createDirectories(storageFile.getParent()); + MAPPER.writeValue(storageFile.toFile(), file.normalize()); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private String normalizeProjectKey(ProjectReference projectReference) { + return Objects.requireNonNull(projectReference, "projectReference") + .rootPath() + .toAbsolutePath() + .normalize() + .toString(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record WindowStateFile(WindowStateRecord launcher, Map projects) { + private static WindowStateFile empty() { + return new WindowStateFile(null, new LinkedHashMap<>()); + } + + private WindowStateFile normalize() { + return new WindowStateFile(launcher, projects == null ? new LinkedHashMap<>() : new LinkedHashMap<>(projects)); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record WindowStateRecord(double x, double y, double width, double height, boolean maximized) { + } +} diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index d035d771..d97c3007 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -1,4 +1,5 @@ app.title=Prometeu Studio +app.projectTitle=Prometeu Studio - {0} menu.file=File menu.file.newProject=New Project @@ -13,9 +14,39 @@ launcher.title=Projects launcher.subtitle=Open an existing project or create a new one to enter the Studio shell. launcher.existingProjects=Existing Projects launcher.openProject=Open Project +launcher.addProject=Add Project +launcher.forgetProject=Forget Project launcher.createProject=Create New Project launcher.projectNamePrompt=Project name launcher.createButton=Create +wizard.title=New Project Wizard +wizard.back=Back +wizard.next=Next +wizard.create=Create Project +wizard.cancel=Cancel +wizard.browse=Browse +wizard.step.name.title=Project Name +wizard.step.name.description=Choose the new project's name. +wizard.step.language.title=Language and Project Layout +wizard.step.language.description=Choose the language, stdlib version, and default source root for the new project. +wizard.step.location.title=Project Location +wizard.step.location.description=Choose the parent directory where the project will be created. +wizard.step.confirm.title=Confirm Project Creation +wizard.step.confirm.description=Review the project details before creating it. +wizard.language.label=Language +wizard.stdlib.label=Stdlib Version +wizard.sourceRoot.label=Source Root +wizard.confirm.name=Name: {0} +wizard.confirm.language=Language: {0} +wizard.confirm.stdlib=Stdlib: {0} +wizard.confirm.sourceRoot=Source Root: {0} +wizard.confirm.location=Location: {0} +wizard.confirm.root=Project Root: {0} +wizard.error.nameRequired=Project name is required. +wizard.error.languageRequired=Project language is required. +wizard.error.stdlibRequired=Stdlib major must be a positive integer. +wizard.error.sourceRootRequired=Source root is required. +wizard.error.locationRequired=Project location is required. toolbar.play=Play toolbar.stop=Stop diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 29c7f483..6ec429b9 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -73,30 +73,68 @@ } .studio-project-launcher { - -fx-background-color: linear-gradient(to bottom, #191919, #101010); + -fx-background-color: linear-gradient(to bottom, #20242c, #14181d); } .studio-launcher-title { - -fx-font-size: 28px; + -fx-font-size: 24px; -fx-font-weight: bold; -fx-text-fill: #f2f2f2; } .studio-launcher-subtitle { - -fx-font-size: 14px; - -fx-text-fill: #a6a6a6; + -fx-font-size: 13px; + -fx-text-fill: #c3ccd6; } .studio-launcher-section-title { -fx-font-size: 15px; -fx-font-weight: bold; - -fx-text-fill: #dcdcdc; + -fx-text-fill: #f0f4f8; } .studio-project-list { - -fx-pref-height: 320px; + -fx-pref-height: 220px; + -fx-background-color: #0f1317; + -fx-control-inner-background: #0f1317; + -fx-background-insets: 0; + -fx-background-radius: 10; + -fx-border-color: #37414d; + -fx-border-radius: 10; + -fx-border-width: 1; + -fx-padding: 6; +} + +.studio-project-list .list-cell { + -fx-background-color: transparent; + -fx-padding: 8 10 8 10; +} + +.studio-project-list .list-cell:hover { + -fx-background-color: #1c2530; +} + +.studio-project-list .list-cell:selected { + -fx-background-color: #27486a; + -fx-background-radius: 8; +} + +.studio-project-list-name { + -fx-text-fill: #f7fbff; + -fx-font-size: 13px; + -fx-font-weight: bold; +} + +.studio-project-list-metadata { + -fx-text-fill: #b8d8f7; + -fx-font-size: 11px; +} + +.studio-project-list-path { + -fx-text-fill: #c0c8d2; + -fx-font-size: 11px; } .studio-launcher-feedback { - -fx-text-fill: #ff9b7d; + -fx-text-fill: #ffb08f; } diff --git a/prometeu-studio/src/test/java/p/studio/projects/KnownProjectsServiceTest.java b/prometeu-studio/src/test/java/p/studio/projects/KnownProjectsServiceTest.java new file mode 100644 index 00000000..1fbdc166 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/projects/KnownProjectsServiceTest.java @@ -0,0 +1,81 @@ +package p.studio.projects; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class KnownProjectsServiceTest { + @TempDir + Path tempDir; + + @Test + void seedsKnownProjectsFromCatalogWhenStorageIsEmpty() throws Exception { + final Path projectsRoot = tempDir.resolve("projects"); + Files.createDirectories(projectsRoot.resolve("alpha")); + Files.createDirectories(projectsRoot.resolve("beta")); + Files.writeString(projectsRoot.resolve("alpha").resolve("prometeu.json"), manifest("Alpha Project")); + Files.writeString(projectsRoot.resolve("beta").resolve("prometeu.json"), manifest("Beta Project")); + + final KnownProjectsService service = new KnownProjectsService( + tempDir.resolve("known-projects.txt"), + new ProjectCatalogService(projectsRoot)); + + assertEquals( + java.util.List.of("Alpha Project", "Beta Project"), + service.listKnownProjects().stream().map(ProjectReference::name).toList()); + } + + @Test + void canForgetProjectWithoutDeletingDirectory() throws Exception { + final Path projectsRoot = tempDir.resolve("projects"); + Files.createDirectories(projectsRoot.resolve("alpha")); + Files.writeString(projectsRoot.resolve("alpha").resolve("prometeu.json"), manifest("Alpha Project")); + final ProjectReference alpha = new ProjectReference("Alpha Project", "1.0.0", "pbs", 1, projectsRoot.resolve("alpha")); + + final KnownProjectsService service = new KnownProjectsService( + tempDir.resolve("known-projects.txt"), + new ProjectCatalogService(projectsRoot)); + + service.remember(alpha); + service.forget(alpha); + + assertEquals(java.util.List.of(), service.listKnownProjects()); + assertEquals(true, Files.isDirectory(alpha.rootPath())); + } + + @Test + void canRememberProjectAgainAfterItWasForgotten() throws Exception { + final Path projectsRoot = tempDir.resolve("projects"); + Files.createDirectories(projectsRoot.resolve("alpha")); + Files.writeString(projectsRoot.resolve("alpha").resolve("prometeu.json"), manifest("Alpha Project")); + final ProjectReference alpha = new ProjectReference("Alpha Project", "1.0.0", "pbs", 1, projectsRoot.resolve("alpha")); + + final KnownProjectsService service = new KnownProjectsService( + tempDir.resolve("known-projects.txt"), + new ProjectCatalogService(projectsRoot)); + + service.remember(alpha); + service.forget(alpha); + service.remember(alpha); + + assertEquals( + java.util.List.of("Alpha Project"), + service.listKnownProjects().stream().map(ProjectReference::name).toList()); + } + + private String manifest(String name) { + return """ + { + "name": "%s", + "version": "1.0.0", + "language": "pbs", + "stdlib": "1", + "dependencies": [] + } + """.formatted(name); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java b/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java index a6e9c063..a8e64a29 100644 --- a/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; final class ProjectCatalogServiceTest { @@ -17,12 +18,15 @@ final class ProjectCatalogServiceTest { void listsExistingProjectDirectories() throws Exception { Files.createDirectories(tempDir.resolve("alpha")); Files.createDirectories(tempDir.resolve("beta")); + Files.createDirectories(tempDir.resolve("scratch")); + Files.writeString(tempDir.resolve("alpha").resolve("prometeu.json"), manifest("Alpha Project")); + Files.writeString(tempDir.resolve("beta").resolve("prometeu.json"), manifest("Beta Project")); Files.createDirectories(tempDir.resolve("alpha").resolve("src")); final ProjectCatalogService service = new ProjectCatalogService(tempDir); assertEquals( - java.util.List.of("alpha", "beta"), + java.util.List.of("Alpha Project", "Beta Project"), service.listProjects().stream().map(ProjectReference::name).toList()); } @@ -32,11 +36,92 @@ final class ProjectCatalogServiceTest { final ProjectReference project = service.createProject("My New Project"); - assertEquals("my-new-project", project.name()); + assertEquals("My New Project", project.name()); + assertEquals("1.0.0", project.version()); + assertEquals("pbs", project.languageId()); + assertEquals(1, project.stdlibVersion()); assertTrue(Files.isDirectory(project.rootPath())); + assertTrue(Files.isRegularFile(project.rootPath().resolve("prometeu.json"))); assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace"))); assertTrue(Files.isDirectory(project.rootPath().resolve("src"))); assertTrue(Files.isDirectory(project.rootPath().resolve("build"))); assertTrue(Files.isDirectory(project.rootPath().resolve("cartridge"))); } + + @Test + void createsProjectInsideExplicitParentLocation() { + final ProjectCatalogService service = new ProjectCatalogService(tempDir.resolve("ignored-default-root")); + final Path customParent = tempDir.resolve("custom-projects"); + + final ProjectReference project = service.createProject("Wizard Project", customParent); + + assertEquals("Wizard Project", project.name()); + assertEquals("1.0.0", project.version()); + assertEquals("pbs", project.languageId()); + assertEquals(1, project.stdlibVersion()); + assertEquals(customParent.resolve("wizard-project"), project.rootPath()); + assertTrue(Files.isDirectory(project.rootPath())); + assertTrue(Files.isRegularFile(project.rootPath().resolve("prometeu.json"))); + assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace"))); + assertTrue(Files.isDirectory(project.rootPath().resolve("src"))); + } + + @Test + void createsProjectUsingRequestedLanguageStdlibAndSourceRoot() throws Exception { + final ProjectCatalogService service = new ProjectCatalogService(tempDir); + + final ProjectReference project = service.createProject(new ProjectCreationRequest( + "Wizard Layout Project", + tempDir, + "pbs", + 7, + "code")); + + assertEquals("Wizard Layout Project", project.name()); + assertEquals("1.0.0", project.version()); + assertEquals("pbs", project.languageId()); + assertEquals(7, project.stdlibVersion()); + assertTrue(Files.isDirectory(project.rootPath().resolve("code"))); + assertTrue(Files.notExists(project.rootPath().resolve("src"))); + final String manifestJson = Files.readString(project.rootPath().resolve("prometeu.json")); + assertTrue(manifestJson.contains("\"language\": \"pbs\"")); + assertTrue(manifestJson.contains("\"stdlib\": \"7\"")); + } + + @Test + void openProjectRejectsDirectoryWithoutPrometeuManifest() throws Exception { + final Path invalidProject = tempDir.resolve("invalid-project"); + Files.createDirectories(invalidProject); + final ProjectCatalogService service = new ProjectCatalogService(tempDir); + + assertThrows(IllegalArgumentException.class, () -> service.openProject(invalidProject)); + } + + @Test + void openProjectUsesManifestNameInsteadOfDirectoryName() throws Exception { + final Path projectRoot = tempDir.resolve("folder-name"); + Files.createDirectories(projectRoot); + Files.writeString(projectRoot.resolve("prometeu.json"), manifest("Display Name")); + final ProjectCatalogService service = new ProjectCatalogService(tempDir); + + final ProjectReference project = service.openProject(projectRoot); + + assertEquals("Display Name", project.name()); + assertEquals("1.0.0", project.version()); + assertEquals("pbs", project.languageId()); + assertEquals(1, project.stdlibVersion()); + assertEquals(projectRoot, project.rootPath()); + } + + private String manifest(String name) { + return """ + { + "name": "%s", + "version": "1.0.0", + "language": "pbs", + "stdlib": "1", + "dependencies": [] + } + """.formatted(name); + } } diff --git a/test-projects/tototo/prometeu.json b/test-projects/tototo/prometeu.json new file mode 100644 index 00000000..96c0e295 --- /dev/null +++ b/test-projects/tototo/prometeu.json @@ -0,0 +1,8 @@ +{ + "name": "tototo", + "version": "1.0.0", + "language": "pbs", + "stdlib": "12346789", + "dependencies": [ + ] +} diff --git a/test-projects/umbelivable/prometeu.json b/test-projects/umbelivable/prometeu.json new file mode 100644 index 00000000..c599bcf5 --- /dev/null +++ b/test-projects/umbelivable/prometeu.json @@ -0,0 +1,8 @@ +{ + "name": "umbelivable", + "version": "1.0.0", + "language": "pbs", + "stdlib": "1", + "dependencies": [ + ] +}