update shell

This commit is contained in:
bQUARKz 2026-03-11 15:22:32 +00:00
parent 824a39436a
commit 0e6a79020e
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
26 changed files with 1498 additions and 105 deletions

View File

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

View File

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

View File

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

View File

@ -3,6 +3,8 @@ package p.studio.compiler;
import p.studio.compiler.models.FrontendSpec; import p.studio.compiler.models.FrontendSpec;
import p.studio.utilities.structures.ReadOnlySet; import p.studio.utilities.structures.ReadOnlySet;
import java.util.List;
public class PBSDefinitions { public class PBSDefinitions {
public static final FrontendSpec PBS = FrontendSpec public static final FrontendSpec PBS = FrontendSpec
.builder() .builder()
@ -10,5 +12,6 @@ public class PBSDefinitions {
.allowedExtensions(ReadOnlySet.from("pbs", "barrel")) .allowedExtensions(ReadOnlySet.from("pbs", "barrel"))
.sourceRoots(ReadOnlySet.from("src")) .sourceRoots(ReadOnlySet.from("src"))
.entryPointCallableName("frame") .entryPointCallableName("frame")
.stdlibVersions(List.of(FrontendSpec.Stdlib.asDefault(1)))
.build(); .build();
} }

View File

@ -4,6 +4,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import p.studio.utilities.structures.ReadOnlySet; import p.studio.utilities.structures.ReadOnlySet;
import java.util.List;
@Builder @Builder
@Getter @Getter
public class FrontendSpec { public class FrontendSpec {
@ -13,8 +15,34 @@ public class FrontendSpec {
private final boolean caseSensitive; private final boolean caseSensitive;
@Builder.Default @Builder.Default
private final String entryPointCallableName = "main"; private final String entryPointCallableName = "main";
@Builder.Default
private final List<Stdlib> stdlibVersions = List.of();
public String toString() { public String toString() {
return String.format("FrontendSpec(language=%s, entryPoint=%s)", languageId, entryPointCallableName); 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);
}
}
} }

View File

@ -5,6 +5,7 @@ import p.studio.compiler.services.FrontendPhaseService;
import p.studio.compiler.services.PBSFrontendPhaseService; import p.studio.compiler.services.PBSFrontendPhaseService;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -28,4 +29,10 @@ public class FrontendRegistryService {
public static Optional<FrontendPhaseService> getFrontendPhaseService(final String languageId) { public static Optional<FrontendPhaseService> getFrontendPhaseService(final String languageId) {
return Optional.ofNullable(FRONTEND_PHASE_SERVICES.get(languageId)); return Optional.ofNullable(FRONTEND_PHASE_SERVICES.get(languageId));
} }
public static List<FrontendSpec> listFrontendSpecs() {
return FRONTEND_SPECS.values().stream()
.sorted((left, right) -> left.getLanguageId().compareToIgnoreCase(right.getLanguageId()))
.toList();
}
} }

View File

@ -7,6 +7,7 @@ dependencies {
implementation(project(":prometeu-infra")) implementation(project(":prometeu-infra"))
implementation(project(":prometeu-compiler:prometeu-compiler-core")) implementation(project(":prometeu-compiler:prometeu-compiler-core"))
implementation(project(":prometeu-compiler:prometeu-build-pipeline")) implementation(project(":prometeu-compiler:prometeu-build-pipeline"))
implementation(project(":prometeu-compiler:prometeu-frontend-registry"))
implementation(libs.javafx.controls) implementation(libs.javafx.controls)
implementation(libs.javafx.fxml) implementation(libs.javafx.fxml)
implementation(libs.richtextfx) implementation(libs.richtextfx)

View File

@ -1,10 +1,8 @@
package p.studio; package p.studio;
import javafx.application.Application; import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage; import javafx.stage.Stage;
import p.studio.utilities.i18n.I18n; import p.studio.window.StudioWindowCoordinator;
import p.studio.window.StudioRootView;
public class App extends Application { public class App extends Application {
@ -16,13 +14,7 @@ public class App extends Application {
@Override @Override
public void start(Stage stage) { public void start(Stage stage) {
var root = new StudioRootView(); new StudioWindowCoordinator(stage).showLauncher();
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();
} }
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -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<ProjectReference> listKnownProjects() {
final boolean storageExists = Files.isRegularFile(storageFile);
final Set<Path> knownPaths = readStoredPaths();
if (!storageExists && knownPaths.isEmpty()) {
final List<ProjectReference> discovered = projectCatalogService.listProjects();
writeStoredPaths(discovered.stream().map(ProjectReference::rootPath).toList());
return discovered;
}
final List<ProjectReference> 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<Path> knownPaths = readStoredPaths();
knownPaths.add(normalize(projectReference.rootPath()));
writeStoredPaths(knownPaths);
}
public void forget(ProjectReference projectReference) {
final Set<Path> knownPaths = readStoredPaths();
knownPaths.remove(normalize(projectReference.rootPath()));
writeStoredPaths(knownPaths);
}
private Set<Path> readStoredPaths() {
if (!Files.isRegularFile(storageFile)) {
return new LinkedHashSet<>();
}
try {
final LinkedHashSet<Path> 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<Path> paths) {
try {
Files.createDirectories(storageFile.getParent());
final List<String> 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();
}
}

View File

@ -1,5 +1,8 @@
package p.studio.projects; package p.studio.projects;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -11,6 +14,8 @@ import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
public final class ProjectCatalogService { public final class ProjectCatalogService {
private static final String MANIFEST_FILE_NAME = "prometeu.json";
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path projectsRoot; private final Path projectsRoot;
public ProjectCatalogService(Path projectsRoot) { public ProjectCatalogService(Path projectsRoot) {
@ -28,8 +33,8 @@ public final class ProjectCatalogService {
try (Stream<Path> children = Files.list(projectsRoot)) { try (Stream<Path> children = Files.list(projectsRoot)) {
return children return children
.filter(Files::isDirectory) .filter(this::isValidProjectRoot)
.map(path -> new ProjectReference(path.getFileName().toString(), path)) .map(this::buildReference)
.sorted(Comparator.comparing(ProjectReference::name, String.CASE_INSENSITIVE_ORDER)) .sorted(Comparator.comparing(ProjectReference::name, String.CASE_INSENSITIVE_ORDER))
.toList(); .toList();
} catch (IOException ioException) { } catch (IOException ioException) {
@ -39,33 +44,146 @@ public final class ProjectCatalogService {
public ProjectReference openProject(Path projectPath) { public ProjectReference openProject(Path projectPath) {
final Path normalized = Objects.requireNonNull(projectPath, "projectPath").toAbsolutePath().normalize(); final Path normalized = Objects.requireNonNull(projectPath, "projectPath").toAbsolutePath().normalize();
if (!Files.isDirectory(normalized)) { return openProjectManifest(manifestPath(normalized));
throw new IllegalArgumentException("project directory does not exist: " + 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) { public ProjectReference createProject(String projectName) {
final String sanitized = sanitizeProjectName(projectName); return createProject(new ProjectCreationRequest(projectName, projectsRoot, "pbs", 1, "src"));
if (sanitized.isBlank()) { }
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"); 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)) { if (Files.exists(projectRoot)) {
throw new IllegalArgumentException("project already exists: " + sanitized); throw new IllegalArgumentException("project already exists: " + sanitized);
} }
try { try {
Files.createDirectories(normalizedParent);
Files.createDirectories(projectRoot.resolve(".workspace")); 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("build"));
Files.createDirectories(projectRoot.resolve("cartridge")); Files.createDirectories(projectRoot.resolve("cartridge"));
Files.writeString(manifestPath(projectRoot), defaultManifest(displayName, languageId, request.stdlib()));
} catch (IOException ioException) { } catch (IOException ioException) {
throw new UncheckedIOException(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) { private String sanitizeProjectName(String rawName) {

View File

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

View File

@ -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<ProjectLanguageTemplate> 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<String> sourceRoots = frontendSpec.getSourceRoots().stream()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
final List<ProjectLanguageTemplate.StdlibOption> 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);
}
}

View File

@ -0,0 +1,24 @@
package p.studio.projects;
import p.studio.compiler.models.FrontendSpec;
import java.util.List;
public record ProjectLanguageTemplate(
String languageId,
List<StdlibOption> stdlibOptions,
StdlibOption defaultStdlib,
List<String> 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)";
};
}
}
}

View File

@ -2,7 +2,12 @@ package p.studio.projects;
import java.nio.file.Path; 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 @Override
public String toString() { public String toString() {
return name; return name;

View File

@ -4,6 +4,7 @@ import lombok.Getter;
public enum I18n { public enum I18n {
APP_TITLE("app.title"), APP_TITLE("app.title"),
APP_PROJECT_TITLE("app.projectTitle"),
MENU_FILE("menu.file"), MENU_FILE("menu.file"),
MENU_FILE_NEWPROJECT("menu.file.newProject"), MENU_FILE_NEWPROJECT("menu.file.newProject"),
@ -20,10 +21,41 @@ public enum I18n {
LAUNCHER_SUBTITLE("launcher.subtitle"), LAUNCHER_SUBTITLE("launcher.subtitle"),
LAUNCHER_EXISTING_PROJECTS("launcher.existingProjects"), LAUNCHER_EXISTING_PROJECTS("launcher.existingProjects"),
LAUNCHER_OPEN_PROJECT("launcher.openProject"), LAUNCHER_OPEN_PROJECT("launcher.openProject"),
LAUNCHER_ADD_PROJECT("launcher.addProject"),
LAUNCHER_FORGET_PROJECT("launcher.forgetProject"),
LAUNCHER_CREATE_PROJECT("launcher.createProject"), LAUNCHER_CREATE_PROJECT("launcher.createProject"),
LAUNCHER_PROJECT_NAME_PROMPT("launcher.projectNamePrompt"), LAUNCHER_PROJECT_NAME_PROMPT("launcher.projectNamePrompt"),
LAUNCHER_CREATE_BUTTON("launcher.createButton"), 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_PLAY("toolbar.play"),
TOOLBAR_STOP("toolbar.stop"), TOOLBAR_STOP("toolbar.stop"),
TOOLBAR_EXPORT("toolbar.export"), TOOLBAR_EXPORT("toolbar.export"),

View File

@ -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<ProjectReference> 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<String> languageCombo = new ComboBox<>();
private final ComboBox<ProjectLanguageTemplate.StdlibOption> stdlibCombo = new ComboBox<>();
private final ComboBox<String> sourceRootCombo = new ComboBox<>();
private final Map<String, ProjectLanguageTemplate> 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<ProjectReference> 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<ProjectLanguageTemplate> 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());
}
}

View File

@ -5,43 +5,65 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView; import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.input.MouseButton;
import javafx.stage.FileChooser;
import p.studio.Container; import p.studio.Container;
import p.studio.projects.KnownProjectsService;
import p.studio.projects.ProjectCatalogService; import p.studio.projects.ProjectCatalogService;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n; import p.studio.utilities.i18n.I18n;
import java.io.File;
import java.nio.file.Path;
import java.util.Objects; import java.util.Objects;
import java.util.function.Consumer; import java.util.function.Consumer;
public final class ProjectLauncherView extends BorderPane { public final class ProjectLauncherView extends BorderPane {
private final KnownProjectsService knownProjectsService;
private final ProjectCatalogService projectCatalogService; private final ProjectCatalogService projectCatalogService;
private final Consumer<ProjectReference> onOpenProject; private final Consumer<ProjectReference> onOpenProject;
private final Consumer<ProjectReference> onAddProject;
private final Consumer<ProjectReference> onCreateProject; private final Consumer<ProjectReference> onCreateProject;
private final Consumer<ProjectReference> onForgetProject;
private final ListView<ProjectReference> projectList = new ListView<>(); private final ListView<ProjectReference> projectList = new ListView<>();
private final TextField projectNameField = new TextField();
private final Label feedbackLabel = new Label(); private final Label feedbackLabel = new Label();
public ProjectLauncherView( public ProjectLauncherView(
KnownProjectsService knownProjectsService,
ProjectCatalogService projectCatalogService, ProjectCatalogService projectCatalogService,
Consumer<ProjectReference> onOpenProject, Consumer<ProjectReference> onOpenProject,
Consumer<ProjectReference> onCreateProject) { Consumer<ProjectReference> onAddProject,
Consumer<ProjectReference> onCreateProject,
Consumer<ProjectReference> onForgetProject) {
this.knownProjectsService = Objects.requireNonNull(knownProjectsService, "knownProjectsService");
this.projectCatalogService = Objects.requireNonNull(projectCatalogService, "projectCatalogService"); this.projectCatalogService = Objects.requireNonNull(projectCatalogService, "projectCatalogService");
this.onOpenProject = Objects.requireNonNull(onOpenProject, "onOpenProject"); this.onOpenProject = Objects.requireNonNull(onOpenProject, "onOpenProject");
this.onAddProject = Objects.requireNonNull(onAddProject, "onAddProject");
this.onCreateProject = Objects.requireNonNull(onCreateProject, "onCreateProject"); this.onCreateProject = Objects.requireNonNull(onCreateProject, "onCreateProject");
this.onForgetProject = Objects.requireNonNull(onForgetProject, "onForgetProject");
getStyleClass().add("studio-project-launcher"); getStyleClass().add("studio-project-launcher");
setPadding(new Insets(24)); setPadding(new Insets(16));
setCenter(buildContent()); setCenter(buildCenteredContent());
reloadProjects(); 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() { private VBox buildContent() {
final Label title = new Label(); final Label title = new Label();
title.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_TITLE)); 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"); listTitle.getStyleClass().add("studio-launcher-section-title");
projectList.getStyleClass().add("studio-project-list"); 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); VBox.setVgrow(projectList, Priority.ALWAYS);
final Button openButton = new Button(); final Button openButton = new Button();
@ -63,27 +91,33 @@ public final class ProjectLauncherView extends BorderPane {
openButton.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull()); openButton.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull());
openButton.setOnAction(ignored -> openSelectedProject()); 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); openRow.setAlignment(Pos.CENTER_LEFT);
final Label createTitle = new Label(); final Label createTitle = new Label();
createTitle.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_PROJECT)); createTitle.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_PROJECT));
createTitle.getStyleClass().add("studio-launcher-section-title"); 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(); final Button createButton = new Button();
createButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_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); createRow.setAlignment(Pos.CENTER_LEFT);
feedbackLabel.getStyleClass().add("studio-launcher-feedback"); feedbackLabel.getStyleClass().add("studio-launcher-feedback");
feedbackLabel.setWrapText(true); feedbackLabel.setWrapText(true);
final VBox content = new VBox(16, final VBox content = new VBox(12,
title, title,
subtitle, subtitle,
listTitle, listTitle,
@ -92,12 +126,12 @@ public final class ProjectLauncherView extends BorderPane {
createTitle, createTitle,
createRow, createRow,
feedbackLabel); feedbackLabel);
content.setMaxWidth(720); content.setMaxWidth(520);
return content; return content;
} }
private void reloadProjects() { public void reloadProjects() {
projectList.setItems(FXCollections.observableArrayList(projectCatalogService.listProjects())); projectList.setItems(FXCollections.observableArrayList(knownProjectsService.listKnownProjects()));
if (!projectList.getItems().isEmpty()) { if (!projectList.getItems().isEmpty()) {
projectList.getSelectionModel().selectFirst(); projectList.getSelectionModel().selectFirst();
} }
@ -115,18 +149,87 @@ public final class ProjectLauncherView extends BorderPane {
onOpenProject.accept(selected); onOpenProject.accept(selected);
} }
private void createProject() { private void forgetSelectedProject() {
try { final ProjectReference selected = projectList.getSelectionModel().getSelectedItem();
final ProjectReference created = projectCatalogService.createProject(projectNameField.getText()); if (selected == null) {
projectNameField.clear(); feedbackLabel.setText("");
return;
}
feedbackLabel.textProperty().unbind();
feedbackLabel.setText("");
onForgetProject.accept(selected);
}
private void openCreateWizard() {
NewProjectWizard.showAndWait(getScene().getWindow(), projectCatalogService).ifPresent(created -> {
reloadProjects(); reloadProjects();
projectList.getSelectionModel().select(created); projectList.getSelectionModel().select(created);
feedbackLabel.textProperty().unbind(); feedbackLabel.textProperty().unbind();
feedbackLabel.setText(""); feedbackLabel.setText("");
onCreateProject.accept(created); 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.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<ProjectReference> {
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);
} }
} }
} }

View File

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

View File

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

View File

@ -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<String, WindowStateRecord> 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) {
}
}

View File

@ -1,4 +1,5 @@
app.title=Prometeu Studio app.title=Prometeu Studio
app.projectTitle=Prometeu Studio - {0}
menu.file=File menu.file=File
menu.file.newProject=New Project 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.subtitle=Open an existing project or create a new one to enter the Studio shell.
launcher.existingProjects=Existing Projects launcher.existingProjects=Existing Projects
launcher.openProject=Open Project launcher.openProject=Open Project
launcher.addProject=Add Project
launcher.forgetProject=Forget Project
launcher.createProject=Create New Project launcher.createProject=Create New Project
launcher.projectNamePrompt=Project name launcher.projectNamePrompt=Project name
launcher.createButton=Create 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.play=Play
toolbar.stop=Stop toolbar.stop=Stop

View File

@ -73,30 +73,68 @@
} }
.studio-project-launcher { .studio-project-launcher {
-fx-background-color: linear-gradient(to bottom, #191919, #101010); -fx-background-color: linear-gradient(to bottom, #20242c, #14181d);
} }
.studio-launcher-title { .studio-launcher-title {
-fx-font-size: 28px; -fx-font-size: 24px;
-fx-font-weight: bold; -fx-font-weight: bold;
-fx-text-fill: #f2f2f2; -fx-text-fill: #f2f2f2;
} }
.studio-launcher-subtitle { .studio-launcher-subtitle {
-fx-font-size: 14px; -fx-font-size: 13px;
-fx-text-fill: #a6a6a6; -fx-text-fill: #c3ccd6;
} }
.studio-launcher-section-title { .studio-launcher-section-title {
-fx-font-size: 15px; -fx-font-size: 15px;
-fx-font-weight: bold; -fx-font-weight: bold;
-fx-text-fill: #dcdcdc; -fx-text-fill: #f0f4f8;
} }
.studio-project-list { .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 { .studio-launcher-feedback {
-fx-text-fill: #ff9b7d; -fx-text-fill: #ffb08f;
} }

View File

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

View File

@ -7,6 +7,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
final class ProjectCatalogServiceTest { final class ProjectCatalogServiceTest {
@ -17,12 +18,15 @@ final class ProjectCatalogServiceTest {
void listsExistingProjectDirectories() throws Exception { void listsExistingProjectDirectories() throws Exception {
Files.createDirectories(tempDir.resolve("alpha")); Files.createDirectories(tempDir.resolve("alpha"));
Files.createDirectories(tempDir.resolve("beta")); 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")); Files.createDirectories(tempDir.resolve("alpha").resolve("src"));
final ProjectCatalogService service = new ProjectCatalogService(tempDir); final ProjectCatalogService service = new ProjectCatalogService(tempDir);
assertEquals( assertEquals(
java.util.List.of("alpha", "beta"), java.util.List.of("Alpha Project", "Beta Project"),
service.listProjects().stream().map(ProjectReference::name).toList()); service.listProjects().stream().map(ProjectReference::name).toList());
} }
@ -32,11 +36,92 @@ final class ProjectCatalogServiceTest {
final ProjectReference project = service.createProject("My New Project"); 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.isDirectory(project.rootPath()));
assertTrue(Files.isRegularFile(project.rootPath().resolve("prometeu.json")));
assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace"))); assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace")));
assertTrue(Files.isDirectory(project.rootPath().resolve("src"))); assertTrue(Files.isDirectory(project.rootPath().resolve("src")));
assertTrue(Files.isDirectory(project.rootPath().resolve("build"))); assertTrue(Files.isDirectory(project.rootPath().resolve("build")));
assertTrue(Files.isDirectory(project.rootPath().resolve("cartridge"))); 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);
}
} }

View File

@ -0,0 +1,8 @@
{
"name": "tototo",
"version": "1.0.0",
"language": "pbs",
"stdlib": "12346789",
"dependencies": [
]
}

View File

@ -0,0 +1,8 @@
{
"name": "umbelivable",
"version": "1.0.0",
"language": "pbs",
"stdlib": "1",
"dependencies": [
]
}