diff --git a/discussion/index.ndjson b/discussion/index.ndjson index c7a26791..d2dfb7d3 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,4 +1,4 @@ -{"type":"meta","next_id":{"DSC":20,"AGD":21,"DEC":18,"PLN":39,"LSN":33,"CLSN":1}} +{"type":"meta","next_id":{"DSC":21,"AGD":22,"DEC":19,"PLN":40,"LSN":34,"CLSN":1}} {"type":"discussion","id":"DSC-0001","status":"done","ticket":"studio-docs-import","title":"Import docs/studio into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0001-assets-workspace-execution-wave-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0002-bank-composition-editor-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0003-mental-model-asset-mutations-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0004-mental-model-assets-workspace-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0005-mental-model-studio-events-and-components-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0006-mental-model-studio-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0007-pack-wizard-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0008-project-scoped-state-and-activity-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0016-studio-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]} {"type":"discussion","id":"DSC-0002","status":"open","ticket":"palette-management-in-studio","title":"Palette Management in Studio","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","legacy-import","palette-management","tile-bank","packer-boundary"],"agendas":[{"id":"AGD-0002","file":"AGD-0002-palette-management-in-studio.md","status":"open","created_at":"2026-03-26","updated_at":"2026-03-26"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0003","status":"done","ticket":"packer-docs-import","title":"Import docs/packer into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["packer","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0009","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0009-mental-model-packer-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0010","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0010-asset-identity-and-runtime-contract-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0011","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0011-foundations-workspace-runtime-and-build-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0012","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0012-runtime-ownership-and-studio-boundary-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0013","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0013-metadata-convergence-and-runtime-sink-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0014","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0014-pack-wizard-summary-validation-and-pack-execution-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0015","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0015-tile-bank-packing-contract-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0017","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0017-packer-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]} @@ -18,3 +18,4 @@ {"type":"discussion","id":"DSC-0017","status":"open","ticket":"studio-editor-inline-type-hints-for-let-bindings","title":"Inline Type Hints for Let Bindings in the Studio Editor","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","inline-hints","inlay-hints","lsp","pbs","type-inference"],"agendas":[{"id":"AGD-0018","file":"AGD-0018-studio-editor-inline-type-hints-for-let-bindings.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0015","file":"DEC-0015-studio-editor-inline-type-hints-contract-and-rendering-model.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03","ref_agenda":"AGD-0018"}],"plans":[{"id":"PLN-0033","file":"PLN-0033-inline-hint-spec-and-contract-propagation.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0034","file":"PLN-0034-lsp-inline-hint-transport-contract.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0035","file":"PLN-0035-pbs-inline-type-hint-payload-production.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0036","file":"PLN-0036-studio-inline-hint-rendering-and-rollout.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]}],"lessons":[]} {"type":"discussion","id":"DSC-0018","status":"done","ticket":"studio-project-local-studio-state-under-dot-studio","title":"Persist project-local Studio state under .studio","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","project-session","project-state","persistence","dot-studio","shell","layout","setup"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"discussion/lessons/DSC-0018-studio-project-local-studio-state-under-dot-studio/LSN-0031-project-local-studio-state-and-lifecycle-safe-layout-restoration.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]} {"type":"discussion","id":"DSC-0019","status":"done","ticket":"studio-project-local-setup-separate-from-main-studio-state","title":"Separate project-local setup from the main Studio state under .studio","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","project-session","project-state","persistence","dot-studio","setup","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0032","file":"discussion/lessons/DSC-0019-studio-project-local-setup-separate-from-main-studio-state/LSN-0032-separate-project-config-from-session-restoration-state.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]} +{"type":"discussion","id":"DSC-0020","status":"done","ticket":"studio-editor-indentation-policy-and-project-setup","title":"Indentation Policy, Status-Bar Semantics, and Project-Local Editor Setup","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","editor","indentation","tabs","setup","dot-studio","configuration","ux"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0033","file":"discussion/lessons/DSC-0020-studio-editor-indentation-policy-and-project-setup/LSN-0033-setup-owned-indentation-policy-and-project-bootstrap-defaults.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]} diff --git a/discussion/lessons/DSC-0020-studio-editor-indentation-policy-and-project-setup/LSN-0033-setup-owned-indentation-policy-and-project-bootstrap-defaults.md b/discussion/lessons/DSC-0020-studio-editor-indentation-policy-and-project-setup/LSN-0033-setup-owned-indentation-policy-and-project-bootstrap-defaults.md new file mode 100644 index 00000000..faa8ecc2 --- /dev/null +++ b/discussion/lessons/DSC-0020-studio-editor-indentation-policy-and-project-setup/LSN-0033-setup-owned-indentation-policy-and-project-bootstrap-defaults.md @@ -0,0 +1,107 @@ +--- +id: LSN-0033 +ticket: studio-editor-indentation-policy-and-project-setup +title: Setup-Owned Indentation Policy and Project Bootstrap Defaults +created: 2026-04-04 +tags: [studio, editor, indentation, tabs, setup, dot-studio, project-creation, gitignore] +--- + +## Context + +The Studio editor exposed an indentation chip in the status bar, but that chip was driven by file-content heuristics rather than by a stable editor policy. +At the same time, `Tab` insertion did not obey the displayed value, which made the editor self-contradictory during normal editing. + +The work also surfaced a second bootstrap problem: project creation already owned `.studio/` and `.workspace/`, but it was not yet treating project-local setup and baseline ignore rules as first-class bootstrapped assets. + +## Key Decisions + +### Project-Local Setup Owns Editor Indentation Policy + +**What:** +The active indentation policy is now owned by `.studio/setup.json`, loaded into memory once per project session, shown in the status bar, and used directly by editable-document `Tab` handling. + +**Why:** +The status bar must show the active policy, not a moving guess. +If `Tab` behavior and the visible chip disagree, the editor loses trust immediately. + +**Trade-offs:** +This first wave intentionally stays project-wide. +It does not attempt per-file or per-language precedence, and it does not reformat existing files that already diverge from the configured policy. + +### Project Creation Must Bootstrap Real Project-Local Setup + +**What:** +The project-creation wizard now includes a dedicated details step before location selection. +That step captures indentation width and Prometeu runtime path and persists them into `.studio/setup.json`. + +**Why:** +Indentation policy and runtime path are project-local configuration, so they should exist from project birth rather than appear later as ad hoc repair. + +**Trade-offs:** +Project creation becomes a little longer, but the result is a better-initialized project and a cleaner setup boundary. + +### New Projects Must Ship With a Baseline `.gitignore` + +**What:** +Project bootstrap now writes a root `.gitignore` with Studio-local state and common OS junk excluded by default. + +**Why:** +`.studio/` and `.workspace/` are local machine artifacts and should not depend on users remembering to ignore them later. +The same applies to common macOS, Windows, and Linux metadata noise. + +**Trade-offs:** +The generated file is intentionally conservative and generic rather than ecosystem-specific. + +## Final Implementation + +The final state established these rules in code and specs: + +- `ProjectLocalStudioSetup` now carries editor indentation configuration with default resolution to `Spaces: 4`. +- `ProjectLocalStudioSetupService` now supports both loading and saving setup data. +- `StudioProjectSession` loads setup once and keeps it available in memory for runtime consumers. +- `EditorStatusBar` now renders configured indentation policy instead of inspecting file contents. +- `EditorWorkspace` converts `Tab` into the configured number of spaces for editable files. +- `NewProjectWizard` now captures indentation width and runtime path on a dedicated details step before location. +- `ProjectCatalogService` persists `.studio/setup.json` and writes a baseline `.gitignore` during project creation. + +## Patterns and Algorithms + +### Pattern: Policy Chips Must Reflect Runtime Policy, Not Content Heuristics + +If a status-bar chip describes how the editor will behave, it must be backed by the same runtime value that input handling uses. +Do not derive policy chips from observed content when the editor behavior is actually driven elsewhere. + +### Pattern: Load Setup Once, Use It Repeatedly + +Project-local setup is durable configuration, not per-keystroke state. +Load it at project-session time, keep the resolved value in memory, and let editor interactions consume that in-memory representation directly. + +### Pattern: Bootstrap Project Hygiene at Creation Time + +If a project requires local-only directories such as `.studio/` or `.workspace/`, generate the corresponding `.gitignore` entries when the project is created. +Do not push that responsibility onto users later. + +## Pitfalls + +- Do not let the status-bar indentation chip switch based on whatever indentation currently exists in the open file. +- Do not make `Tab` obey one rule while the chip displays another. +- Do not reread `.studio/setup.json` on every editing interaction just because the file is durable configuration. +- Do not auto-reformat preexisting file content just because a project now has a configured indentation policy. +- Do not keep `.studio/` and `.workspace/` out of `.gitignore` and expect users to fix repository hygiene manually. + +## References + +- `docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md` +- `docs/specs/studio/5. Code Editor Workspace Specification.md` +- `docs/specs/studio/8. Project-Local Studio State Specification.md` +- `prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetup.java` +- `prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetupService.java` +- `prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java` +- `prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java` +- `prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java` + +## Takeaways + +- Editor policy must be explicit, stable, and shared between UI and input handling. +- `.studio/setup.json` is the right owner for project-local editor configuration such as indentation and runtime path. +- Project creation should bootstrap both configuration and repository hygiene, not leave them as follow-up chores. diff --git a/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md b/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md index b03cfe2c..dcfa325c 100644 --- a/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md +++ b/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md @@ -35,6 +35,7 @@ Baseline project entry behavior is: - show a lightweight launcher or home surface first; - show existing or recent projects; - expose `Open Project` and `Create Project` as first-class actions; +- the project-creation flow may include an optional extra-details step for project-local setup such as editor indentation policy; - enter the main workspace shell only after a concrete project is selected or created. ## Workspace Model diff --git a/docs/specs/studio/5. Code Editor Workspace Specification.md b/docs/specs/studio/5. Code Editor Workspace Specification.md index dc2f28cf..0b386601 100644 --- a/docs/specs/studio/5. Code Editor Workspace Specification.md +++ b/docs/specs/studio/5. Code Editor Workspace Specification.md @@ -200,6 +200,11 @@ Rules: - the global shell `Save` menu item must not be the save surface for this wave; - save intent must route through `prometeu-vfs`, which remains the owner of persistence policy; - a frontend hard `read-only` tab must show a top warning that the file cannot be edited or saved in this wave; +- the active indentation policy for editable documents MUST come from project-local setup rather than from file-content heuristics; +- the status-bar indentation chip MUST display the active configured policy, not inferred file contents; +- pressing `Tab` in an editable document MUST insert spaces according to the active configured indentation width; +- the active indentation policy MUST remain stable while the user edits document contents; +- opening a file whose existing indentation diverges from the configured policy MUST NOT trigger automatic reformatting; - and the workspace must not define local merge/conflict behavior against disk changes. ## Outline Rules diff --git a/docs/specs/studio/8. Project-Local Studio State Specification.md b/docs/specs/studio/8. Project-Local Studio State Specification.md index f211775c..70b9a703 100644 --- a/docs/specs/studio/8. Project-Local Studio State Specification.md +++ b/docs/specs/studio/8. Project-Local Studio State Specification.md @@ -61,6 +61,9 @@ Rules: - project-local setup MUST live in `.studio/setup.json`; - project-local setup MUST be treated as project configuration rather than session state; - project-local setup MAY contain runtime-oriented values such as the Prometeu runtime path; +- project-local setup MUST own the project-wide editor indentation policy; +- the editor indentation policy MUST define at least indentation mode and indentation width; +- when setup does not provide an explicit indentation policy, consumers MUST resolve it to `spaces` with width `4`; - project-local setup MAY grow with additional manual or automatic configuration keys over time; - consumers such as `Play` MUST read project-local setup from the dedicated setup file rather than from `.studio/state.json`; - this revision uses a dry format change and MUST NOT preserve compatibility with the obsolete shape where `projectLocalSetup` was embedded in `.studio/state.json`. 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 79944088..7f22c97b 100644 --- a/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java @@ -2,6 +2,8 @@ package p.studio.projects; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.ObjectMapper; +import p.studio.projectstate.ProjectLocalStudioSetup; +import p.studio.projectstate.ProjectLocalStudioSetupService; import java.io.IOException; import java.io.UncheckedIOException; @@ -17,6 +19,7 @@ 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 ProjectLocalStudioSetupService projectLocalStudioSetupService = new ProjectLocalStudioSetupService(); public ProjectCatalogService(Path projectsRoot) { this.projectsRoot = Objects.requireNonNull(projectsRoot, "projectsRoot").toAbsolutePath().normalize(); @@ -67,11 +70,11 @@ public final class ProjectCatalogService { } public ProjectReference createProject(String projectName) { - return createProject(new ProjectCreationRequest(projectName, projectsRoot, "pbs", 1, "src")); + return createProject(new ProjectCreationRequest(projectName, projectsRoot, "pbs", 1, "src", 4, null)); } public ProjectReference createProject(String projectName, Path parentLocation) { - return createProject(new ProjectCreationRequest(projectName, parentLocation, "pbs", 1, "src")); + return createProject(new ProjectCreationRequest(projectName, parentLocation, "pbs", 1, "src", 4, null)); } public ProjectReference createProject(ProjectCreationRequest request) { @@ -87,6 +90,9 @@ public final class ProjectCatalogService { if (request.stdlib() <= 0) { throw new IllegalArgumentException("project stdlib major must be positive"); } + if (request.indentationWidth() <= 0) { + throw new IllegalArgumentException("project indentation width must be positive"); + } final Path sourceRoot = normalizeSourceRoot(request.sourceRoot()); final Path normalizedParent = Objects.requireNonNull(request.parentLocation(), "request.parentLocation") @@ -104,17 +110,24 @@ public final class ProjectCatalogService { try { Files.createDirectories(normalizedParent); Files.createDirectories(projectRoot.resolve(".workspace")); - Files.createDirectories(ProjectStudioPaths.studioRoot(new ProjectReference( + final ProjectReference projectReference = new ProjectReference( displayName, "1.0.0", languageId, request.stdlib(), - projectRoot))); + projectRoot); + Files.createDirectories(ProjectStudioPaths.studioRoot(projectReference)); 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())); + Files.writeString(projectRoot.resolve(".gitignore"), defaultGitIgnore()); + projectLocalStudioSetupService.save( + projectReference, + new ProjectLocalStudioSetup( + request.runtimePath(), + new ProjectLocalStudioSetup.EditorIndentationSetup("spaces", request.indentationWidth()))); } catch (IOException ioException) { throw new UncheckedIOException(ioException); } @@ -188,6 +201,27 @@ public final class ProjectCatalogService { """.formatted(projectName, languageId, stdlibMajor); } + private String defaultGitIgnore() { + return """ + .studio/ + .workspace/ + build/ + + # macOS + .DS_Store + .AppleDouble + .LSOverride + + # Windows + Thumbs.db + Desktop.ini + + # Linux / Unix desktop metadata + .directory + *~ + """; + } + @JsonIgnoreProperties(ignoreUnknown = true) private record ProjectManifestSummary(String name, String version, String language, String stdlib) { } diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java index e912be3b..e06005ec 100644 --- a/prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectCreationRequest.java @@ -7,5 +7,7 @@ public record ProjectCreationRequest( Path parentLocation, String languageId, int stdlib, - String sourceRoot) { + String sourceRoot, + int indentationWidth, + String runtimePath) { } diff --git a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java index 146ad4de..b6c9fe29 100644 --- a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java +++ b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java @@ -1,6 +1,7 @@ package p.studio.projectsessions; import p.studio.lsp.LspService; +import p.studio.projectstate.ProjectLocalStudioSetup; import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projectstate.ProjectLocalStudioStateService; import p.studio.projects.ProjectReference; @@ -13,6 +14,7 @@ public final class StudioProjectSession implements AutoCloseable { private final LspService prometeuLspService; private final VfsProjectDocument vfsProjectDocument; private final ProjectLocalStudioStateService projectLocalStudioStateService; + private final ProjectLocalStudioSetup projectLocalStudioSetup; private ProjectLocalStudioState projectLocalStudioState; private boolean closed; @@ -25,6 +27,7 @@ public final class StudioProjectSession implements AutoCloseable { prometeuLspService, vfsProjectDocument, new ProjectLocalStudioStateService(), + ProjectLocalStudioSetup.defaults(), ProjectLocalStudioState.defaults()); } @@ -33,11 +36,13 @@ public final class StudioProjectSession implements AutoCloseable { final LspService prometeuLspService, final VfsProjectDocument vfsProjectDocument, final ProjectLocalStudioStateService projectLocalStudioStateService, + final ProjectLocalStudioSetup projectLocalStudioSetup, final ProjectLocalStudioState projectLocalStudioState) { this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); this.prometeuLspService = Objects.requireNonNull(prometeuLspService, "prometeuLspService"); this.vfsProjectDocument = Objects.requireNonNull(vfsProjectDocument, "vfsProjectDocument"); this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService"); + this.projectLocalStudioSetup = Objects.requireNonNull(projectLocalStudioSetup, "projectLocalStudioSetup"); this.projectLocalStudioState = Objects.requireNonNull(projectLocalStudioState, "projectLocalStudioState"); } @@ -57,6 +62,10 @@ public final class StudioProjectSession implements AutoCloseable { return projectLocalStudioState; } + public ProjectLocalStudioSetup projectLocalStudioSetup() { + return projectLocalStudioSetup; + } + public void replaceProjectLocalStudioState(final ProjectLocalStudioState nextProjectLocalStudioState) { this.projectLocalStudioState = Objects.requireNonNull(nextProjectLocalStudioState, "nextProjectLocalStudioState"); } diff --git a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java index 83f39213..63e4af74 100644 --- a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java +++ b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java @@ -2,6 +2,7 @@ package p.studio.projectsessions; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.LspServiceFactory; +import p.studio.projectstate.ProjectLocalStudioSetupService; import p.studio.projectstate.ProjectLocalStudioStateService; import p.studio.projects.ProjectReference; import p.studio.vfs.VfsProjectDocument; @@ -13,20 +14,27 @@ public final class StudioProjectSessionFactory { private final LspServiceFactory lspServiceFactory; private final ProjectDocumentVfsFactory projectDocumentVfsFactory; private final ProjectLocalStudioStateService projectLocalStudioStateService; + private final ProjectLocalStudioSetupService projectLocalStudioSetupService; public StudioProjectSessionFactory( final LspServiceFactory lspServiceFactory, final ProjectDocumentVfsFactory projectDocumentVfsFactory) { - this(lspServiceFactory, projectDocumentVfsFactory, new ProjectLocalStudioStateService()); + this( + lspServiceFactory, + projectDocumentVfsFactory, + new ProjectLocalStudioStateService(), + new ProjectLocalStudioSetupService()); } StudioProjectSessionFactory( final LspServiceFactory lspServiceFactory, final ProjectDocumentVfsFactory projectDocumentVfsFactory, - final ProjectLocalStudioStateService projectLocalStudioStateService) { + final ProjectLocalStudioStateService projectLocalStudioStateService, + final ProjectLocalStudioSetupService projectLocalStudioSetupService) { this.lspServiceFactory = Objects.requireNonNull(lspServiceFactory, "prometeuLspServiceFactory"); this.projectDocumentVfsFactory = Objects.requireNonNull(projectDocumentVfsFactory, "projectDocumentVfsFactory"); this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService"); + this.projectLocalStudioSetupService = Objects.requireNonNull(projectLocalStudioSetupService, "projectLocalStudioSetupService"); } public StudioProjectSession open(final ProjectReference projectReference) { @@ -37,6 +45,7 @@ public final class StudioProjectSessionFactory { lspServiceFactory.open(lspProjectContext(target), vfsProjectDocument), vfsProjectDocument, projectLocalStudioStateService, + projectLocalStudioSetupService.load(target), projectLocalStudioStateService.load(target)); } diff --git a/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetup.java b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetup.java index d40ab2cc..aba2747f 100644 --- a/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetup.java +++ b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetup.java @@ -3,13 +3,18 @@ package p.studio.projectstate; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonIgnoreProperties(ignoreUnknown = true) -public record ProjectLocalStudioSetup(String prometeuRuntimePath) { +public record ProjectLocalStudioSetup( + String prometeuRuntimePath, + EditorIndentationSetup editorIndentation) { public ProjectLocalStudioSetup { prometeuRuntimePath = normalizeText(prometeuRuntimePath); + editorIndentation = editorIndentation == null + ? EditorIndentationSetup.defaults() + : editorIndentation.normalize(); } public static ProjectLocalStudioSetup defaults() { - return new ProjectLocalStudioSetup(null); + return new ProjectLocalStudioSetup(null, EditorIndentationSetup.defaults()); } private static String normalizeText(final String value) { @@ -19,4 +24,57 @@ public record ProjectLocalStudioSetup(String prometeuRuntimePath) { final String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record EditorIndentationSetup( + String mode, + Integer width) { + private static final String DEFAULT_MODE = "spaces"; + private static final int DEFAULT_WIDTH = 4; + + public EditorIndentationSetup { + mode = normalizeMode(mode); + width = normalizeWidth(width); + } + + public static EditorIndentationSetup defaults() { + return new EditorIndentationSetup(DEFAULT_MODE, DEFAULT_WIDTH); + } + + public EditorIndentationSetup normalize() { + return new EditorIndentationSetup(mode, width); + } + + public String statusLabel() { + return switch (mode) { + case "tabs" -> "Tabs: " + width; + default -> "Spaces: " + width; + }; + } + + public String tabInsertion() { + return " ".repeat(width); + } + + private static String normalizeMode(final String value) { + if (value == null) { + return DEFAULT_MODE; + } + final String normalized = value.trim().toLowerCase(); + if (normalized.isBlank()) { + return DEFAULT_MODE; + } + return switch (normalized) { + case "tabs", "spaces" -> normalized; + default -> DEFAULT_MODE; + }; + } + + private static Integer normalizeWidth(final Integer value) { + if (value == null || value <= 0) { + return DEFAULT_WIDTH; + } + return value; + } + } } diff --git a/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetupService.java b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetupService.java index c483db79..22b0f26e 100644 --- a/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetupService.java +++ b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioSetupService.java @@ -7,8 +7,9 @@ import p.studio.projects.ProjectStudioPaths; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Objects; -public final class ProjectLocalStudioSetupService { +public class ProjectLocalStudioSetupService { private static final ObjectMapper MAPPER = new ObjectMapper(); public ProjectLocalStudioSetup load(final ProjectReference projectReference) { @@ -24,4 +25,15 @@ public final class ProjectLocalStudioSetupService { return ProjectLocalStudioSetup.defaults(); } } + + public void save(final ProjectReference projectReference, final ProjectLocalStudioSetup setup) { + final Path setupPath = ProjectStudioPaths.setupPath(projectReference); + final ProjectLocalStudioSetup normalized = Objects.requireNonNull(setup, "setup"); + try { + Files.createDirectories(setupPath.getParent()); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(setupPath.toFile(), normalized); + } catch (IOException ioException) { + throw new RuntimeException("unable to save project setup: " + setupPath, ioException); + } + } } 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 8e759fb2..1ac4c466 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 @@ -49,22 +49,30 @@ public enum I18n { 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_DETAILS_TITLE("wizard.step.details.title"), + WIZARD_STEP_DETAILS_DESCRIPTION("wizard.step.details.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_INDENTATION_LABEL("wizard.indentation.label"), + WIZARD_RUNTIME_LABEL("wizard.runtime.label"), + WIZARD_RUNTIME_PROMPT("wizard.runtime.prompt"), 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_INDENTATION("wizard.confirm.indentation"), + WIZARD_CONFIRM_RUNTIME("wizard.confirm.runtime"), 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"), + WIZARD_ERROR_INDENTATION_REQUIRED("wizard.error.indentationRequired"), TOOLBAR_PLAY("toolbar.play"), TOOLBAR_STOP("toolbar.stop"), diff --git a/prometeu-studio/src/main/java/p/studio/window/MainView.java b/prometeu-studio/src/main/java/p/studio/window/MainView.java index a92e7cf3..53c0d49b 100644 --- a/prometeu-studio/src/main/java/p/studio/window/MainView.java +++ b/prometeu-studio/src/main/java/p/studio/window/MainView.java @@ -3,6 +3,7 @@ package p.studio.window; import javafx.scene.layout.BorderPane; import p.studio.Container; import p.studio.controls.shell.*; +import p.studio.projectstate.ProjectLocalStudioSetup; import p.studio.lsp.events.StudioWorkspaceSelectedEvent; import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projects.ProjectReference; @@ -31,6 +32,7 @@ public final class MainView extends BorderPane { this.projectSession = projectSession; this.projectReference = projectSession.projectReference(); final ProjectLocalStudioState persistedState = projectSession.projectLocalStudioState(); + final ProjectLocalStudioSetup projectSetup = projectSession.projectLocalStudioSetup(); final var menuBar = new StudioShellMenuBarControl(); final var runSurface = new StudioRunSurfaceControl(); setTop(new StudioShellTopBarControl(menuBar)); @@ -40,7 +42,8 @@ public final class MainView extends BorderPane { editorWorkspace = new EditorWorkspace( projectReference, projectSession.projectDocumentVfs(), - projectSession.prometeuLspService()); + projectSession.prometeuLspService(), + projectSetup.editorIndentation()); host.register(editorWorkspace); assetWorkspace.restoreProjectLocalState(persistedState); editorWorkspace.restoreProjectLocalState(persistedState); diff --git a/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java b/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java index ace05bce..77ceb63c 100644 --- a/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java +++ b/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java @@ -37,9 +37,11 @@ public final class NewProjectWizard { private final Button createButton = new Button(); private final TextField projectNameField = new TextField(); private final TextField locationField = new TextField(); + private final TextField runtimePathField = new TextField(); private final ComboBox languageCombo = new ComboBox<>(); private final ComboBox stdlibCombo = new ComboBox<>(); private final ComboBox sourceRootCombo = new ComboBox<>(); + private final ComboBox indentationWidthCombo = new ComboBox<>(); private final Map languageTemplatesById = new LinkedHashMap<>(); private int stepIndex = 0; @@ -55,6 +57,9 @@ public final class NewProjectWizard { projectNameField.promptTextProperty().bind(Container.i18n().bind(I18n.LAUNCHER_PROJECT_NAME_PROMPT)); locationField.setText(projectCatalogService.projectsRoot().toString()); + runtimePathField.promptTextProperty().bind(Container.i18n().bind(I18n.WIZARD_RUNTIME_PROMPT)); + indentationWidthCombo.getItems().setAll(2, 4, 8); + indentationWidthCombo.getSelectionModel().select(Integer.valueOf(4)); initializeLanguageTemplates(); renderStep(); @@ -101,16 +106,17 @@ public final class NewProjectWizard { 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); + nextButton.setVisible(stepIndex < 4); + nextButton.setManaged(stepIndex < 4); + createButton.setVisible(stepIndex == 4); + createButton.setManaged(stepIndex == 4); switch (stepIndex) { case 0 -> renderNameStep(); case 1 -> renderLanguageStep(); - case 2 -> renderLocationStep(); - case 3 -> renderConfirmStep(); + case 2 -> renderDetailsStep(); + case 3 -> renderLocationStep(); + case 4 -> renderConfirmStep(); default -> throw new IllegalStateException("unknown wizard step: " + stepIndex); } } @@ -171,6 +177,8 @@ public final class NewProjectWizard { 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 projectIndentation = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_INDENTATION, selectedIndentationLabel())); + final Label projectRuntime = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_RUNTIME, selectedRuntimePathLabel())); final Label projectRoot = new Label(Container.i18n().format(I18n.WIZARD_CONFIRM_ROOT, resolvedProjectRoot().toString())); stepBody.getChildren().setAll( @@ -178,10 +186,34 @@ public final class NewProjectWizard { projectLanguage, projectStdlib, projectSourceRoot, + projectIndentation, + projectRuntime, projectLocation, projectRoot); } + private void renderDetailsStep() { + stepTitle.textProperty().unbind(); + stepDescription.textProperty().unbind(); + stepTitle.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_DETAILS_TITLE)); + stepDescription.textProperty().bind(Container.i18n().bind(I18n.WIZARD_STEP_DETAILS_DESCRIPTION)); + + final Label indentationLabel = new Label(Container.i18n().text(I18n.WIZARD_INDENTATION_LABEL)); + final Label runtimeLabel = new Label(Container.i18n().text(I18n.WIZARD_RUNTIME_LABEL)); + final Button runtimeBrowseButton = new Button(); + runtimeBrowseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE)); + runtimeBrowseButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + runtimeBrowseButton.setOnAction(ignored -> browseForRuntimePath()); + indentationWidthCombo.setMaxWidth(Double.MAX_VALUE); + runtimePathField.setMaxWidth(Double.MAX_VALUE); + final VBox indentationBox = new VBox(6, indentationLabel, indentationWidthCombo); + final HBox runtimeRow = new HBox(12, runtimePathField, runtimeBrowseButton); + runtimeRow.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(runtimePathField, Priority.ALWAYS); + final VBox runtimeBox = new VBox(6, runtimeLabel, runtimeRow); + stepBody.getChildren().setAll(indentationBox, runtimeBox); + } + private void goBack() { if (stepIndex == 0) { return; @@ -202,7 +234,8 @@ public final class NewProjectWizard { return switch (stepIndex) { case 0 -> validateName(); case 1 -> validateLanguageSettings(); - case 2 -> validateLocation(); + case 2 -> validateDetails(); + case 3 -> validateLocation(); default -> true; }; } @@ -246,6 +279,14 @@ public final class NewProjectWizard { return true; } + private boolean validateDetails() { + if (selectedIndentationWidth() <= 0) { + feedbackLabel.setText(Container.i18n().text(I18n.WIZARD_ERROR_INDENTATION_REQUIRED)); + return false; + } + return true; + } + private void finishCreate() { if (!validateName() || !validateLanguageSettings() || !validateLocation()) { return; @@ -257,7 +298,9 @@ public final class NewProjectWizard { Path.of(locationField.getText().trim()), selectedLanguageId(), selectedStdlib().version(), - selectedSourceRoot()))); + selectedSourceRoot(), + selectedIndentationWidth(), + selectedRuntimePath()))); stage.close(); } catch (IllegalArgumentException exception) { feedbackLabel.setText(exception.getMessage()); @@ -282,6 +325,24 @@ public final class NewProjectWizard { } } + private void browseForRuntimePath() { + final DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(Container.i18n().text(I18n.WIZARD_RUNTIME_LABEL)); + + final String currentText = runtimePathField.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) { + runtimePathField.setText(selected.toPath().toAbsolutePath().normalize().toString()); + } + } + private String sanitizedName() { return projectNameField.getText() == null ? "" : projectNameField.getText().trim(); } @@ -330,6 +391,26 @@ public final class NewProjectWizard { return selectedStdlib == null ? "" : selectedStdlib.toString(); } + private int selectedIndentationWidth() { + return indentationWidthCombo.getValue() == null ? 4 : indentationWidthCombo.getValue(); + } + + private String selectedIndentationLabel() { + return "Spaces: " + selectedIndentationWidth(); + } + + private String selectedRuntimePath() { + if (runtimePathField.getText() == null) { + return null; + } + final String trimmed = runtimePathField.getText().trim(); + return trimmed.isBlank() ? null : trimmed; + } + + private String selectedRuntimePathLabel() { + return selectedRuntimePath() == null ? "-" : selectedRuntimePath(); + } + private Path resolvedProjectRoot() { return Path.of(locationField.getText().trim()).toAbsolutePath().normalize().resolve(sanitizedName()); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java index 8756c025..dde4b197 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java @@ -15,7 +15,6 @@ import java.nio.file.Path; import java.util.Optional; public final class EditorStatusBar extends HBox { - private static final String DEFAULT_INDENTATION = "Spaces: 4"; private final HBox breadcrumb = new HBox(6); private final Label position = new Label(); private final Label lineSeparator = new Label(); @@ -59,11 +58,12 @@ public final class EditorStatusBar extends HBox { public void showFile( final ProjectReference projectReference, final EditorOpenFileBuffer fileBuffer, - final EditorDocumentPresentation presentation) { + final EditorDocumentPresentation presentation, + final String indentationPolicyLabel) { showBreadcrumb(projectReference, fileBuffer.path()); showMetadata(true); showPosition(fileBuffer.editable(), 1, 1); - showDocumentFormatting(fileBuffer.lineSeparator(), fileBuffer.content()); + showDocumentFormatting(fileBuffer.lineSeparator(), indentationPolicyLabel); setText(language, fileBuffer.typeId()); EditorDocumentPresentationStyles.applyToStatusChip(language, presentation); showAccessMode(fileBuffer.readOnly()); @@ -88,9 +88,9 @@ public final class EditorStatusBar extends HBox { setText(position, line + ":" + column); } - public void showDocumentFormatting(final String separator, final String content) { + public void showDocumentFormatting(final String separator, final String indentationPolicyLabel) { setText(lineSeparator, separator); - setText(indentation, detectIndentation(content)); + setText(indentation, indentationPolicyLabel); } private void bindDefault(final Label label, final I18n key) { @@ -215,58 +215,4 @@ public final class EditorStatusBar extends HBox { label.getStyleClass().add("editor-workspace-status-chip"); } } - - private String detectIndentation(final String content) { - final String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n'); - int spaceGcd = 0; - boolean sawTabs = false; - for (final String line : normalized.split("\n", -1)) { - if (line.isBlank()) { - continue; - } - int spaces = 0; - int tabs = 0; - while (spaces + tabs < line.length()) { - final char ch = line.charAt(spaces + tabs); - if (ch == ' ') { - if (tabs > 0) { - spaces = 0; - break; - } - spaces++; - continue; - } - if (ch == '\t') { - tabs++; - continue; - } - break; - } - if (tabs > 0 && spaces == 0) { - sawTabs = true; - continue; - } - if (spaces > 1) { - spaceGcd = spaceGcd == 0 ? spaces : gcd(spaceGcd, spaces); - } - } - if (sawTabs) { - return "Tabs: 1"; - } - if (spaceGcd > 0) { - return "Spaces: " + spaceGcd; - } - return DEFAULT_INDENTATION; - } - - private int gcd(final int left, final int right) { - int a = Math.abs(left); - int b = Math.abs(right); - while (b != 0) { - final int next = a % b; - a = b; - b = next; - } - return a == 0 ? 1 : a; - } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index 391dbc62..21f882d2 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -15,6 +15,7 @@ import org.reactfx.Subscription; import p.studio.lsp.LspService; import p.studio.lsp.messages.LspAnalyzeDocumentRequest; import p.studio.lsp.messages.LspAnalyzeDocumentResult; +import p.studio.projectstate.ProjectLocalStudioSetup; import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; @@ -45,6 +46,7 @@ public final class EditorWorkspace extends Workspace { private final EditorDocumentPresentationRegistry presentationRegistry = new EditorDocumentPresentationRegistry(); private final LspService prometeuLspService; private final VfsProjectDocument vfsProjectDocument; + private final ProjectLocalStudioSetup.EditorIndentationSetup indentationSetup; private final EditorOpenFileSession openFileSession = new EditorOpenFileSession(); private final Subscription inlineHintChangeSubscription; private final List activePresentationStylesheets = new ArrayList<>(); @@ -66,10 +68,12 @@ public final class EditorWorkspace extends Workspace { public EditorWorkspace( final ProjectReference projectReference, final VfsProjectDocument vfsProjectDocument, - final LspService prometeuLspService) { + final LspService prometeuLspService, + final ProjectLocalStudioSetup.EditorIndentationSetup indentationSetup) { super(projectReference); this.vfsProjectDocument = Objects.requireNonNull(vfsProjectDocument, "vfsProjectDocument"); this.prometeuLspService = Objects.requireNonNull(prometeuLspService, "prometeuLspService"); + this.indentationSetup = Objects.requireNonNull(indentationSetup, "indentationSetup"); root.getStyleClass().add("editor-workspace"); refreshParagraphGraphics(); codeArea.setEditable(false); @@ -231,7 +235,7 @@ public final class EditorWorkspace extends Workspace { codeArea.setEditable(fileBuffer.editable()); EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); refreshCommandSurfaces(fileBuffer); - statusBar.showFile(projectReference, fileBuffer, presentation); + statusBar.showFile(projectReference, fileBuffer, presentation, indentationSetup.statusLabel()); refreshStatusBarCaret(); refreshSemanticOutline(fileBuffer, analysis); } @@ -374,7 +378,8 @@ public final class EditorWorkspace extends Workspace { final String sourceContent = inlineHintProjection.stripDecorations(content); final VfsDocumentOpenResult.VfsTextDocument updatedDocument = vfsProjectDocument.updateDocument(activeFile.path(), sourceContent); openFileSession.open(bufferFrom(updatedDocument)); - statusBar.showDocumentFormatting(updatedDocument.lineSeparator(), updatedDocument.content()); + refreshEditableHighlighting(updatedDocument); + statusBar.showDocumentFormatting(updatedDocument.lineSeparator(), indentationSetup.statusLabel()); tabStrip.showOpenFiles( openFileSession.openFiles(), openFileSession.activeFile().map(EditorOpenFileBuffer::path).orElse(null)); @@ -382,6 +387,15 @@ public final class EditorWorkspace extends Workspace { }); } + private void refreshEditableHighlighting(final VfsDocumentOpenResult.VfsTextDocument updatedDocument) { + final EditorDocumentPresentation presentation = presentationRegistry.resolve(updatedDocument.typeId()); + inlineHintProjection = EditorInlineHintProjection.create( + updatedDocument.content(), + presentation.highlight(updatedDocument.content()), + List.of()); + codeArea.setStyleSpans(0, inlineHintProjection.displayStyles()); + } + private void saveActiveFile() { openFileSession.activeFile() .filter(EditorOpenFileBuffer::saveEnabled) @@ -505,6 +519,11 @@ public final class EditorWorkspace extends Workspace { } return; } + if (event.getCode() == KeyCode.TAB) { + event.consume(); + codeArea.replaceSelection(indentationSetup.tabInsertion()); + return; + } if (event.getCode() == KeyCode.BACK_SPACE && inlineHintProjection.containsOffset(Math.max(0, caret - 1))) { event.consume(); codeArea.moveTo(inlineHintProjection.clampCaret(Math.max(0, caret - 1), false)); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingBash.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingBash.java index e884e6f6..3623b37e 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingBash.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/syntaxhighlight/EditorDocumentSyntaxHighlightingBash.java @@ -10,15 +10,17 @@ public class EditorDocumentSyntaxHighlightingBash { + "|(?(?m)(?\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')" + "|(?\\$\\{?[A-Za-z_][A-Za-z0-9_]*}?|\\$[0-9@*#?!$-])" + + "|(?(?m)^[ \\t]*test\\b)" + "|(?(?m)^[ \\t]*[A-Za-z_./-][A-Za-z0-9_./-]*)" + "|(?\\b(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time)\\b)" - + "|(?\\b(?:echo|printf|read|cd|pwd|export|local|readonly|unset|return|shift|source|trap|exit|test)\\b)" + + "|(?\\b(?:echo|printf|read|cd|pwd|export|local|readonly|unset|return|shift|source|trap|exit)\\b)" + "|(?\\|\\||&&|;;|<<-?|>>|[|&;<>~=(){}\\[\\]])"), List.of( new EditorDocumentHighlightToken("SHEBANG", "editor-syntax-bash-shebang"), new EditorDocumentHighlightToken("COMMENT", "editor-syntax-bash-comment"), new EditorDocumentHighlightToken("STRING", "editor-syntax-bash-string"), new EditorDocumentHighlightToken("VARIABLE", "editor-syntax-bash-variable"), + new EditorDocumentHighlightToken("TESTBUILTIN", "editor-syntax-bash-builtin"), new EditorDocumentHighlightToken("KEYWORD", "editor-syntax-bash-keyword"), new EditorDocumentHighlightToken("BUILTIN", "editor-syntax-bash-builtin"), new EditorDocumentHighlightToken("COMMAND", "editor-syntax-bash-command"), diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 095a74e9..37f91c59 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -41,22 +41,30 @@ 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.details.title=Extra Details +wizard.step.details.description=Configure project-local editor and runtime details before choosing the project location. 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.indentation.label=Indentation Width +wizard.runtime.label=Prometeu Runtime Path +wizard.runtime.prompt=/opt/prometeu/runtime 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.indentation=Indentation: {0} +wizard.confirm.runtime=Runtime: {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. +wizard.error.indentationRequired=Indentation width must be selected. toolbar.play=Play toolbar.stop=Stop 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 6a244515..36554f98 100644 --- a/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java @@ -40,11 +40,20 @@ final class ProjectCatalogServiceTest { assertEquals(1, project.stdlibVersion()); assertTrue(Files.isDirectory(project.rootPath())); assertTrue(Files.isRegularFile(project.rootPath().resolve("prometeu.json"))); + assertTrue(Files.isRegularFile(project.rootPath().resolve(".gitignore"))); assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace"))); assertTrue(Files.isDirectory(project.rootPath().resolve(".studio"))); + assertTrue(Files.isRegularFile(project.rootPath().resolve(".studio").resolve("setup.json"))); assertTrue(Files.isDirectory(project.rootPath().resolve("src"))); assertTrue(Files.isDirectory(project.rootPath().resolve("build"))); assertTrue(Files.isDirectory(project.rootPath().resolve("cartridge"))); + final String gitIgnore = assertDoesNotThrow(() -> Files.readString(project.rootPath().resolve(".gitignore"))); + assertTrue(gitIgnore.contains(".studio/")); + assertTrue(gitIgnore.contains(".workspace/")); + assertTrue(gitIgnore.contains("build/")); + assertTrue(gitIgnore.contains(".DS_Store")); + assertTrue(gitIgnore.contains("Thumbs.db")); + assertTrue(gitIgnore.contains(".directory")); } @Test @@ -75,7 +84,9 @@ final class ProjectCatalogServiceTest { tempDir, "pbs", 7, - "code")); + "code", + 8, + "/opt/prometeu/runtime")); assertEquals("Wizard Layout Project", project.name()); assertEquals("1.0.0", project.version()); @@ -84,8 +95,12 @@ final class ProjectCatalogServiceTest { assertTrue(Files.isDirectory(project.rootPath().resolve("code"))); assertTrue(Files.notExists(project.rootPath().resolve("src"))); final String manifestJson = Files.readString(project.rootPath().resolve("prometeu.json")); + final String setupJson = Files.readString(project.rootPath().resolve(".studio").resolve("setup.json")); assertTrue(manifestJson.contains("\"language\": \"pbs\"")); assertTrue(manifestJson.contains("\"stdlib\": \"7\"")); + assertTrue(setupJson.contains("\"prometeuRuntimePath\" : \"/opt/prometeu/runtime\"")); + assertTrue(setupJson.contains("\"mode\" : \"spaces\"")); + assertTrue(setupJson.contains("\"width\" : 8")); } @Test diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java index 4a25f450..9f0d51c3 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.LspService; import p.studio.lsp.LspServiceFactory; +import p.studio.projectstate.ProjectLocalStudioSetup; +import p.studio.projectstate.ProjectLocalStudioSetupService; import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projectstate.ProjectLocalStudioStateService; import p.studio.lsp.dtos.LspSessionStateDTO; @@ -30,7 +32,12 @@ final class StudioProjectSessionFactoryTest { final RecordingLspFactory lspFactory = new RecordingLspFactory(); final RecordingVfsFactory vfsFactory = new RecordingVfsFactory(); final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService(); - final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory(lspFactory, vfsFactory, stateService); + final RecordingProjectLocalStudioSetupService setupService = new RecordingProjectLocalStudioSetupService(); + final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory( + lspFactory, + vfsFactory, + stateService, + setupService); final ProjectReference projectReference = new ProjectReference( "Example", "1.0.0", @@ -50,7 +57,9 @@ final class StudioProjectSessionFactoryTest { assertEquals("pbs", lspFactory.capturedContext.languageId()); assertSame(vfsFactory.vfs, lspFactory.capturedVfs); assertSame(stateService.loadedState, session.projectLocalStudioState()); + assertSame(setupService.loadedSetup, session.projectLocalStudioSetup()); assertSame(projectReference, stateService.loadedProjectReference); + assertSame(projectReference, setupService.loadedProjectReference); } private static final class RecordingProjectLocalStudioStateService extends ProjectLocalStudioStateService { @@ -68,6 +77,19 @@ final class StudioProjectSessionFactoryTest { } } + private static final class RecordingProjectLocalStudioSetupService extends ProjectLocalStudioSetupService { + private ProjectReference loadedProjectReference; + private final ProjectLocalStudioSetup loadedSetup = new ProjectLocalStudioSetup( + null, + new ProjectLocalStudioSetup.EditorIndentationSetup("spaces", 8)); + + @Override + public ProjectLocalStudioSetup load(final ProjectReference projectReference) { + this.loadedProjectReference = projectReference; + return loadedSetup; + } + } + private static final class RecordingVfsFactory implements ProjectDocumentVfsFactory { private VfsProjectContext capturedContext; private final VfsProjectDocument vfs = new NoOpVfsProjectDocument(); diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java index 1fad3287..15876b23 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java @@ -3,6 +3,7 @@ package p.studio.projectsessions; import org.junit.jupiter.api.Test; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.LspService; +import p.studio.projectstate.ProjectLocalStudioSetup; import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projectstate.ProjectLocalStudioStateService; import p.studio.lsp.dtos.LspSessionStateDTO; @@ -33,6 +34,7 @@ final class StudioProjectSessionTest { lsp, vfs, stateService, + ProjectLocalStudioSetup.defaults(), ProjectLocalStudioState.defaults()); session.close(); @@ -54,6 +56,7 @@ final class StudioProjectSessionTest { lsp, vfs, stateService, + ProjectLocalStudioSetup.defaults(), ProjectLocalStudioState.defaults()); final ProjectLocalStudioState nextState = ProjectLocalStudioState.defaults() .withOpenShellState(new ProjectLocalStudioState.OpenShellState("EDITOR")); diff --git a/prometeu-studio/src/test/java/p/studio/projectstate/ProjectLocalStudioSetupServiceTest.java b/prometeu-studio/src/test/java/p/studio/projectstate/ProjectLocalStudioSetupServiceTest.java index 72334a81..a52b07fd 100644 --- a/prometeu-studio/src/test/java/p/studio/projectstate/ProjectLocalStudioSetupServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectstate/ProjectLocalStudioSetupServiceTest.java @@ -23,11 +23,19 @@ final class ProjectLocalStudioSetupServiceTest { ProjectStudioPaths.setupPath(project), """ { - "prometeuRuntimePath": "/opt/prometeu/runtime" + "prometeuRuntimePath": "/opt/prometeu/runtime", + "editorIndentation": { + "mode": "spaces", + "width": 2 + } } """); - assertEquals(new ProjectLocalStudioSetup("/opt/prometeu/runtime"), service.load(project)); + assertEquals( + new ProjectLocalStudioSetup( + "/opt/prometeu/runtime", + new ProjectLocalStudioSetup.EditorIndentationSetup("spaces", 2)), + service.load(project)); } @Test @@ -47,6 +55,24 @@ final class ProjectLocalStudioSetupServiceTest { assertEquals(ProjectLocalStudioSetup.defaults(), service.load(project)); } + @Test + void saveWritesNormalizedDedicatedProjectSetupFile() throws Exception { + final ProjectLocalStudioSetupService service = new ProjectLocalStudioSetupService(); + final ProjectReference project = project("main"); + + service.save( + project, + new ProjectLocalStudioSetup( + " /opt/prometeu/runtime ", + new ProjectLocalStudioSetup.EditorIndentationSetup("spaces", 8))); + + assertEquals( + new ProjectLocalStudioSetup( + "/opt/prometeu/runtime", + new ProjectLocalStudioSetup.EditorIndentationSetup("spaces", 8)), + service.load(project)); + } + private ProjectReference project(final String name) { final Path projectRoot = tempDir.resolve(name); return new ProjectReference("Main", "1.0.0", "pbs", 1, projectRoot); diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java index ed65a71a..fd9a563e 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentHighlightingRouterTest.java @@ -260,6 +260,41 @@ final class EditorDocumentHighlightingRouterTest { assertTrue(result.inlineHints().isEmpty()); } + @Test + void bashBuiltinTestOnlyHighlightsInCommandPosition() { + final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry(); + final EditorOpenFileBuffer commandBuffer = new EditorOpenFileBuffer( + Path.of("/tmp/example/run.sh"), + "run.sh", + "bash", + "test -f ./file\n", + "LF", + false, + VfsDocumentAccessMode.EDITABLE, + false); + final EditorOpenFileBuffer plainTextBuffer = new EditorOpenFileBuffer( + Path.of("/tmp/example/run.sh"), + "run.sh", + "bash", + "echo \"$test\"\n", + "LF", + false, + VfsDocumentAccessMode.EDITABLE, + false); + + final EditorDocumentHighlightingResult commandResult = EditorDocumentHighlightingRouter.route( + commandBuffer, + registry.resolve("bash"), + null); + final EditorDocumentHighlightingResult plainTextResult = EditorDocumentHighlightingRouter.route( + plainTextBuffer, + registry.resolve("bash"), + null); + + assertTrue(containsStyle(commandResult.styleSpans(), "editor-syntax-bash-builtin")); + assertFalse(containsStyle(plainTextResult.styleSpans(), "editor-syntax-bash-builtin")); + } + private boolean containsStyle( final org.fxmisc.richtext.model.StyleSpans> styleSpans, final String styleClass) { diff --git a/test-projects/fragments/.gitignore b/test-projects/fragments/.gitignore new file mode 100644 index 00000000..eac60a76 --- /dev/null +++ b/test-projects/fragments/.gitignore @@ -0,0 +1,16 @@ +.studio/ +.workspace/ +build/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +Desktop.ini + +# Linux / Unix desktop metadata +.directory +*~ diff --git a/test-projects/main/.gitignore b/test-projects/main/.gitignore new file mode 100644 index 00000000..eac60a76 --- /dev/null +++ b/test-projects/main/.gitignore @@ -0,0 +1,16 @@ +.studio/ +.workspace/ +build/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +Desktop.ini + +# Linux / Unix desktop metadata +.directory +*~