implements PLN-0062

This commit is contained in:
bQUARKz 2026-05-05 11:12:37 +01:00
parent 85d7211b3c
commit cebef90118
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
50 changed files with 591 additions and 4563 deletions

View File

@ -1,4 +1,5 @@
{"type":"meta","next_id":{"DSC":31,"AGD":34,"DEC":31,"PLN":62,"LSN":47,"CLSN":1}}
{"type":"meta","next_id":{"DSC":32,"AGD":35,"DEC":32,"PLN":65,"LSN":47,"CLSN":1}}
{"type":"discussion","id":"DSC-0031","status":"in_progress","ticket":"studio-editor-workspace-cleanup","title":"Limpeza completa do Workspace Editor do Studio e remoção dos acoplamentos legados","created_at":"2026-05-05","updated_at":"2026-05-05","tags":["studio","editor","cleanup","vfs","lsp","migration"],"agendas":[{"id":"AGD-0034","file":"AGD-0034-vscode-editor-migration-feasibility.md","status":"accepted","created_at":"2026-05-05","updated_at":"2026-05-05"}],"decisions":[{"id":"DEC-0031","file":"DEC-0031-studio-editor-workspace-cleanup.md","status":"in_progress","created_at":"2026-05-05","updated_at":"2026-05-05","ref_agenda":"AGD-0034"}],"plans":[{"id":"PLN-0062","file":"PLN-0062-shell-session-and-state-cleanup-after-editor-removal.md","status":"done","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0063","file":"PLN-0063-legacy-lsp-and-vfs-module-removal-with-runtime-preservation.md","status":"open","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0064","file":"PLN-0064-normative-test-and-lesson-cleanup-for-editor-stack-removal.md","status":"open","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0030","status":"done","ticket":"studio-scene-pack-runtime-binary-contract","title":"Studio scene pack contract for runtime SCENE binary payload","created_at":"2026-04-24","updated_at":"2026-05-01","tags":["studio","packer","runtime","scene","asset-pack","binary-format","tiled"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0046","file":"discussion/lessons/DSC-0030-studio-scene-pack-runtime-binary-contract/LSN-0046-canonical-scene-owns-editorial-truth-while-pack-stays-request-driven.md","status":"done","created_at":"2026-05-01","updated_at":"2026-05-01"}]}
{"type":"discussion","id":"DSC-0029","status":"done","ticket":"studio-frame-composer-syscall-and-sprite-alignment","title":"Studio Alignment with Runtime FrameComposer Syscalls and Sprite Composition","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["studio","compiler","pbs","stdlib","runtime-alignment","abi","syscall","frame-composer","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0041","file":"discussion/lessons/DSC-0029-studio-frame-composer-syscall-and-sprite-alignment/LSN-0041-composer-must-own-public-sprite-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]}
{"type":"discussion","id":"DSC-0028","status":"done","ticket":"studio-tiled-parser-assets-scene-asset-type","title":"Tiled Parser and Scene Asset-Type Ownership in Assets Workspace","created_at":"2026-04-15","updated_at":"2026-04-23","tags":["studio","assets","scene","tiled","parser","asset-type"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0045","file":"discussion/lessons/DSC-0028-studio-tiled-parser-assets-scene-asset-type/LSN-0045-asset-owned-scene-bank-and-tiled-xml-workflow.md","status":"done","created_at":"2026-04-23","updated_at":"2026-04-23"}]}

View File

@ -0,0 +1,106 @@
---
id: AGD-0034
ticket: studio-editor-workspace-cleanup
title: Limpeza completa do Workspace Editor do Studio e remoção dos acoplamentos legados
status: accepted
created: 2026-05-05
resolved: 2026-05-05
decision: DEC-0031
tags: [studio, editor, cleanup, vfs, lsp, migration]
---
## Pain
O repositório ainda carrega um `Workspace Editor` no `prometeu-studio` junto com camadas de `VFS` e `LSP` desenhadas especificamente para esse editor embutido.
Esse conjunto virou um legado arquitetural:
- o editor JavaFX não é mais a direção do produto;
- o `prometeu-vfs` atual está acoplado ao modelo editorial do editor embutido;
- o `prometeu-lsp` atual está acoplado ao `VFS` e ao host local do Studio;
- e manter essa base viva só aumenta o custo da futura reconstrução correta do backend de código.
## Context
- O monorepo já separa `prometeu-compiler`, `prometeu-packer` e `prometeu-studio`, mas o `prometeu-lsp` e o `prometeu-vfs` atuais nasceram como suporte ao editor embutido.
- A capacidade de compilação dos projetos deve continuar existindo no Studio.
- O alvo imediato não é discutir o novo LSP nem o novo contrato de automação.
- O alvo imediato é remover:
- o `Workspace Editor`;
- o `prometeu-lsp` legado usado por ele;
- e o `prometeu-vfs` acoplado a esse modelo editorial.
- O Studio continua existindo como produto fora do editor:
- shell e launcher de projeto;
- assets workspace;
- shipper/build surfaces;
- play/stop e integração com runtime/debug.
- Uma agenda futura poderá discutir a reconstrução do backend de código e do novo servidor LSP, mas isso fica fora deste artefato.
## Open Questions
- Quais partes do `prometeu-vfs` atual pertencem exclusivamente ao `Workspace Editor` e podem ser removidas integralmente?
- Existe algum consumer ainda válido do `prometeu-lsp` legado fora do editor embutido?
- O fluxo `play` ou qualquer outro fluxo do Studio depende hoje, mesmo indiretamente, de APIs do editor legado, do `VFS` legado ou do `LSP` legado?
- A limpeza deve remover apenas código de produção ou também specs, testes, lessons e referências que tratam o editor embutido como superfície ativa?
- Quais contratos de compilação precisam ser preservados explicitamente enquanto a camada de edição é removida?
## Options
### Option A - Manter o editor legado até a reconstrução completa do backend de código
- **Approach:** adiar a limpeza e preservar temporariamente `Workspace Editor`, `prometeu-vfs` legado e `prometeu-lsp` legado até o novo backend existir.
- **Pro:** menor ruptura imediata.
- **Con:** prolonga a vida do legado, aumenta custo de manutenção e deixa a futura reconstrução mais confusa.
- **Maintainability:** fraca.
### Option B - Limpeza completa agora, preservando apenas a compilação do Studio
- **Approach:** remover `Workspace Editor`, `prometeu-lsp` legado e o `prometeu-vfs` acoplado a esse modelo, mantendo apenas as superfícies de compilação e execução que não dependem desse stack.
- **Pro:** reduz o legado antes da reconstrução, clarifica boundaries e impede que o futuro LSP nasça comprometido por compatibilidade acidental.
- **Con:** cria um período sem editor embutido e sem backend de código novo dentro do Studio.
- **Maintainability:** forte.
Vamos de Option B.
## Discussion
O trabalho desta agenda não é desenhar o novo backend de código.
O trabalho desta agenda é remover o legado que impediria esse redesenho.
Isso implica alguns cortes diretos:
- o `Workspace Editor` deixa de ser superfície ativa do Studio;
- o `prometeu-lsp` atual deixa de ser tratado como base evolutiva;
- o `prometeu-vfs` atual, por ser acoplado ao modelo editorial do editor embutido, sai junto com esse stack;
- as capacidades de compilação, build e execução que não dependem desse stack devem ser preservadas.
O princípio aqui é de limpeza arquitetural antes de reconstrução:
- não carregar compatibilidade acidental com o editor antigo;
- não manter um `VFS` ou `LSP` cujo contrato nasce de um modelo que já foi abandonado;
- e não misturar, nesta discussão, a limpeza do legado com o desenho do futuro servidor LSP.
Isso também reduz o risco de falsa migração:
- em vez de adaptar o editor atual, o repositório assume explicitamente que ele vai morrer;
- em vez de reaproveitar um `LSP` embrionário acoplado ao host local, o repositório aceita que um novo backend de código será desenhado depois;
- e em vez de preservar o `VFS` por inércia, o repositório valida se ele ainda possui valor fora do modelo que o originou.
## Resolution
Recomendação preliminar:
- seguir com a Option B;
- tratar esta agenda exclusivamente como limpeza completa do `Workspace Editor` do Studio;
- remover junto o `prometeu-lsp` legado e o `prometeu-vfs` acoplado a esse modelo;
- preservar explicitamente a capacidade de compilação e os fluxos do Studio que não dependem desse stack;
- e abrir uma discussão futura separada para o backend de código novo, o novo LSP e o contrato de automação.
## Next Step
Se a direção fizer sentido, o próximo passo disciplinado é fechar uma `decision` com:
1. o escopo exato da remoção do `Workspace Editor`;
2. o escopo exato da remoção do `prometeu-lsp` legado;
3. o escopo exato da remoção do `prometeu-vfs` acoplado a esse modelo;
4. as capacidades de compilação e execução que devem sobreviver intactas;
5. e os artefatos normativos, testes e código que precisam ser limpos ou preservados.

View File

@ -0,0 +1,148 @@
---
id: DEC-0031
ticket: studio-editor-workspace-cleanup
title: Limpeza completa do Workspace Editor do Studio e remoção dos acoplamentos legados
status: in_progress
created: 2026-05-05
accepted: 2026-05-05
agenda: AGD-0034
plans: [PLN-0062, PLN-0063, PLN-0064]
tags: [studio, editor, cleanup, vfs, lsp, migration]
---
## Decision
O repositório SHALL remover integralmente o `Workspace Editor` do Studio como superfície ativa do produto.
Essa remoção SHALL incluir:
1. o workspace de código embutido em `prometeu-studio`;
2. o `prometeu-lsp` legado usado por esse workspace;
3. o `prometeu-vfs` legado acoplado ao modelo editorial desse workspace;
4. os pontos de integração do shell, da sessão de projeto e do bootstrap que assumem a existência desse stack;
5. e os artefatos normativos, testes e referências documentais que tratam esse stack como superfície vigente.
Ao mesmo tempo, esta decisão ALSO locks que:
1. a capacidade de compilação dos projetos MUST permanecer disponível no Studio;
2. os fluxos atuais de `build`, `play`, `run` e `debug` MUST permanecer funcionais desde que não dependam do editor legado, do `prometeu-lsp` legado ou do `prometeu-vfs` legado;
3. esta decisão MUST NOT introduzir o novo backend de código, o novo servidor LSP ou o futuro contrato de automação;
4. qualquer reconstrução dessas superfícies MUST acontecer em discussão posterior e separada.
## Rationale
O `Workspace Editor`, o `prometeu-lsp` atual e o `prometeu-vfs` atual formam um stack único, criado especificamente para o editor embutido do Studio.
Esse stack não é mais alinhado à direção do produto:
1. o editor JavaFX deixou de ser a direção principal;
2. o `prometeu-lsp` atual não deve ser tratado como base séria para um futuro servidor LSP;
3. o `prometeu-vfs` atual carrega um contrato editorial pensado para o editor embutido;
4. manter esse legado vivo aumenta o custo de reconstrução e preserva boundaries errados por inércia.
Portanto, a limpeza precisa acontecer antes da reconstrução.
## Technical Specification
### 1. Editor Workspace Removal
O Studio MUST remover a superfície `Code Editor` do shell.
Isso inclui:
1. remover `WorkspaceId.EDITOR`;
2. remover o `EditorWorkspace` e todos os controles, painéis, modelos e helpers sob `prometeu-studio/.../workspaces/editor`;
3. remover a presença do editor na workspace rail, no layout principal e no fluxo de restauração de shell;
4. remover strings, ícones e estado visual exclusivos dessa superfície.
### 2. Legacy LSP Removal
O repositório MUST remover o `prometeu-lsp` legado como módulo ativo.
Isso inclui:
1. remover os módulos `prometeu-lsp-api` e `prometeu-lsp-v1`;
2. remover `LspService`, `LspServiceFactory` e todos os DTOs/messages específicos desse stack legado;
3. remover a criação e injeção dessas dependências no `AppContainer`, no `Container`, na sessão de projeto e nos testes associados;
4. remover a hipótese de que esse `LSP` será evoluído para o futuro backend de código.
Os conceitos úteis descobertos nesse stack MAY ser reaproveitados mais tarde, mas esta decisão não preserva compatibilidade de código, pacote ou contrato com ele.
### 3. Legacy VFS Removal
O repositório MUST remover o `prometeu-vfs` legado como módulo ativo.
Isso inclui:
1. remover os módulos `prometeu-vfs-api` e `prometeu-vfs-v1`;
2. remover `VfsProjectDocument`, `ProjectDocumentVfsFactory` e implementações filesystem-backed associadas;
3. remover a criação e posse desse `VFS` na sessão de projeto;
4. remover qualquer regra que trate esse `VFS` como boundary documental vigente do Studio.
Esta decisão assume que o `prometeu-vfs` atual pertence ao modelo do editor legado e, portanto, não deve sobreviver por compatibilidade acidental.
### 4. Session and Shell Cleanup
O Studio MUST reconfigurar a sessão de projeto e o shell principal para não depender do stack removido.
Isso inclui:
1. remover `LSP` e `VFS` legados da estrutura de `StudioProjectSession`;
2. remover sua criação em `StudioProjectSessionFactory`;
3. remover dependências globais correspondentes do bootstrap e do container;
4. remover persistência de layout e restauração editorial específicas do editor.
### 5. State Cleanup
O estado local do projeto MUST deixar de persistir dados exclusivos do editor legado.
Isso inclui:
1. remover `editorRestoration`;
2. remover `shellLayout.editorLayout`;
3. garantir fallback seguro para estados antigos já persistidos em `.studio/state.json`;
4. preservar apenas os campos ainda pertencentes a workspaces vivos e ao shell remanescente.
### 6. Documentation and Test Cleanup
O repositório MUST limpar artefatos normativos e testes que descrevem o stack legado como vigente.
Isso inclui:
1. revisar ou remover as specs do `Code Editor`, do `Project Document VFS` e do `Integrated LSP Semantic Read Phase`;
2. revisar `docs/specs/studio/README.md`;
3. remover ou arquivar testes exclusivos do editor, do `prometeu-lsp` legado e do `prometeu-vfs` legado;
4. revisar lessons e referências históricas para deixar explícito que se tratam de legado, quando forem mantidas.
### 7. Capabilities That MUST Survive
As seguintes capacidades MUST sobreviver a esta limpeza:
1. compilação de projetos pelo pipeline canônico existente;
2. build de artefatos via `BuilderPipelineService`;
3. preparação do fluxo de shipper;
4. `play`, `run` e `debug` do Studio, desde que apoiados nas superfícies remanescentes e não no stack removido;
5. workspaces `Assets`, `Shipper` e `Debug`, além do launcher e do shell geral.
### 8. Explicit Non-Goals
Esta decisão MUST NOT:
1. definir o novo servidor LSP;
2. definir o novo contrato de automação;
3. definir o novo modelo de sessão de código;
4. definir integração com VS Code;
5. reter compatibilidade com o `LSP` ou `VFS` legados por conveniência.
## Constraints
1. A limpeza MUST preservar o pipeline canônico `analyze`, `compile` e `build` já existente no compiler.
2. A limpeza MUST evitar remover ou degradar fluxos de `build/run/debug` que hoje dependem apenas de `compiler`, `shipper`, runtime e shell.
3. A limpeza MUST deixar o shell do Studio em estado coerente, sem workspace fantasma, sem layout órfão e sem dependências mortas.
4. A limpeza MUST tratar specs e estado persistido como parte do trabalho, não como detalhe pós-código.
5. A futura reconstrução do backend de código SHALL nascer em discussão separada, sem herdar obrigação de compatibilidade com o stack removido.
## Revision Log
- 2026-05-05: Initial draft from AGD-0034.
- 2026-05-05: Accepted and decomposed into PLN-0062, PLN-0063, and PLN-0064.

View File

@ -0,0 +1,91 @@
---
id: PLN-0062
ticket: studio-editor-workspace-cleanup
title: Shell Session and State Cleanup After Editor Removal
status: done
created: 2026-05-05
completed: 2026-05-05
tags: [studio, editor, cleanup, shell, session, state]
---
## Objective
Remove the `Code Editor` workspace from the Studio shell and reconfigure project session and persisted state so the application remains coherent without any editor-owned concepts.
## Background
`DEC-0031` locks the complete removal of the embedded editor stack. The shell, project session, workspace registry, and `.studio/state.json` currently still assume the existence of `WorkspaceId.EDITOR`, `EditorWorkspace`, editor restoration state, and editor-specific layout state.
## Scope
### Included
- Remove `WorkspaceId.EDITOR` from the Studio shell.
- Remove `EditorWorkspace` registration and shell integration.
- Remove editor-specific state persistence and restoration paths.
- Remove `LSP`/`VFS` legacy ownership from `StudioProjectSession` and `StudioProjectSessionFactory`.
- Keep `Assets`, `Shipper`, `Debug`, launcher, and shell flows functional.
### Excluded
- Removal of the `prometeu-lsp` and `prometeu-vfs` module source trees themselves.
- Documentation and lesson cleanup.
- New code backend or replacement LSP architecture.
## Execution Steps
### Step 1 - Remove editor workspace registration from the shell
**What:** Remove all shell-level references to the `Code Editor` workspace.
**How:** Update the main window composition to stop creating `EditorWorkspace`, remove the editor rail item, stop selecting or restoring `WorkspaceId.EDITOR`, and ensure shell fallback remains stable when old persisted state still references the editor.
**File(s):** `prometeu-studio/src/main/java/p/studio/window/MainView.java`, `prometeu-studio/src/main/java/p/studio/workspaces/WorkspaceId.java`, any directly related shell controls or selectors.
### Step 2 - Remove editor-owned state and restoration from project-local state
**What:** Remove editor layout and restoration data from persisted project-local Studio state.
**How:** Simplify `ProjectLocalStudioState` to remove `editorRestoration` and `shellLayout.editorLayout`, update normalization/defaults, and make loading old `.studio/state.json` degrade safely without crashes or stale editor assumptions.
**File(s):** `prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioState.java`, `prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioStateService.java`, any callers that write or restore editor state.
### Step 3 - Reconfigure project session to stop owning editor legacy dependencies
**What:** Remove legacy editor-driven `LSP` and `VFS` ownership from project session composition.
**How:** Refactor `StudioProjectSession` and `StudioProjectSessionFactory` so opening a project no longer constructs `VfsProjectDocument` or `LspService`, and so close semantics remain valid without those resources.
**File(s):** `prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java`, `prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java`, dependent tests.
### Step 4 - Remove bootstrap/container dependencies that only existed for the editor stack
**What:** Remove application bootstrap and container seams that exist only to inject legacy editor stack services.
**How:** Delete `LspServiceFactory` and `ProjectDocumentVfsFactory` accessors from the container surfaces once shell/session no longer depend on them, and update `AppContainer` accordingly.
**File(s):** `prometeu-studio/src/main/java/p/studio/Container.java`, `prometeu-app/src/main/java/p/studio/AppContainer.java`, directly affected tests.
## Test Requirements
### Unit Tests
- Update session and state tests to reflect the absence of editor state.
- Add coverage for loading old persisted project-local state without editor restoration fields being required.
### Integration Tests
- Verify the main Studio shell opens a project and activates a surviving workspace without editor registration.
- Verify session close still saves valid project-local state and shuts down surviving services cleanly.
### Manual Verification
- Open the Studio shell and confirm only surviving workspaces appear.
- Reopen a project with stale `.studio/state.json` containing editor data and confirm safe fallback.
- Confirm project switching and shell startup remain stable.
## Acceptance Criteria
- [x] `WorkspaceId.EDITOR` no longer exists in production code.
- [x] `MainView` no longer instantiates or restores `EditorWorkspace`.
- [x] Project-local state no longer persists editor restoration or editor layout fields.
- [x] `StudioProjectSession` no longer owns legacy editor `LSP`/`VFS` resources.
- [x] The Studio shell remains functional with surviving workspaces.
## Dependencies
- `DEC-0031` accepted and normatively locked.
- Knowledge of all shell/session/state call sites that currently assume the editor exists.
## Risks
- Shell restoration may break if old persisted state is not normalized defensively.
- Session teardown may regress if hidden resource ownership remains after field removal.
- Removing editor assumptions too early may temporarily orphan callers still using editor-specific state APIs.

View File

@ -0,0 +1,92 @@
---
id: PLN-0063
ticket: studio-editor-workspace-cleanup
title: Legacy LSP and VFS Module Removal with Runtime Preservation
status: open
created: 2026-05-05
completed:
tags: [studio, editor, cleanup, lsp, vfs, runtime]
---
## Objective
Remove the legacy `prometeu-lsp` and `prometeu-vfs` modules and all remaining code references to them while preserving compiler, shipper, play, run, and debug flows that do not depend on the editor stack.
## Background
`DEC-0031` treats the current `prometeu-lsp` and `prometeu-vfs` modules as part of the embedded editor legacy stack. The Studio execution path must continue to compile, build, package, and run projects through the existing compiler and runtime orchestration layers.
## Scope
### Included
- Remove `prometeu-lsp` modules from Gradle settings and codebase references.
- Remove `prometeu-vfs` modules from Gradle settings and codebase references.
- Remove tests dedicated to these legacy modules.
- Verify `StudioShipperService`, `StudioPlayStopCoordinator`, and runtime/debug flows remain intact.
### Excluded
- Shell/editor state cleanup handled by a separate plan.
- Documentation/spec/lesson cleanup handled by a separate plan.
- New replacement backend services.
## Execution Steps
### Step 1 - Remove Gradle module registration for legacy editor stack modules
**What:** Stop building the legacy `prometeu-lsp` and `prometeu-vfs` modules.
**How:** Remove the module includes and any direct module dependencies that point to `prometeu-lsp:*` and `prometeu-vfs:*`, then fix resulting build graph fallout in Studio modules.
**File(s):** `settings.gradle.kts`, affected `build.gradle.kts` files in `prometeu-app`, `prometeu-studio`, and module directories.
### Step 2 - Remove legacy module source trees and direct test coverage
**What:** Delete the legacy `prometeu-lsp` and `prometeu-vfs` code once all consumers are detached.
**How:** Remove module source files and their dedicated tests only after production call sites no longer compile against them.
**File(s):** `prometeu-lsp/**`, `prometeu-vfs/**`, related test sources in `prometeu-studio`.
### Step 3 - Preserve compiler and shipper build flow
**What:** Ensure build preparation still uses the canonical compiler pipeline without editor legacy dependencies.
**How:** Validate and adjust `StudioShipperService` and any shipper/build callers so they continue using `BuilderPipelineService`, `BuilderPipelineConfig`, and `BuilderPipelineContext` directly without reintroducing removed editor-layer seams.
**File(s):** `prometeu-studio/src/main/java/p/studio/shipper/StudioShipperService.java`, affected compiler/shipper integration tests.
### Step 4 - Preserve play, run, and debug execution flow
**What:** Ensure runtime orchestration continues to function after legacy module removal.
**How:** Re-test and patch `StudioPlayStopCoordinator`, runtime handshake services, execution session handling, and debug workspace projections if any hidden import or event dependency pointed at removed editor-stack modules.
**File(s):** `prometeu-studio/src/main/java/p/studio/execution/StudioPlayStopCoordinator.java`, `prometeu-studio/src/main/java/p/studio/debug/runtime/**`, `prometeu-studio/src/main/java/p/studio/workspaces/debug/**`, related tests.
## Test Requirements
### Unit Tests
- Update or remove module-specific tests for `prometeu-lsp` and `prometeu-vfs`.
- Preserve unit coverage for execution session, play/stop coordination, and runtime handshake behavior.
### Integration Tests
- Run compiler pipeline integration tests that cover `analyze`, `compile`, and `build`.
- Run shipper/build integration tests that exercise `StudioShipperService`.
- Run runtime/debug integration or coordinator tests that validate play/stop behavior.
### Manual Verification
- Trigger Studio build flow and confirm artifact generation still works.
- Trigger Studio play flow and confirm runtime launch/stop still works.
- Open Debug and Shipper workspaces and confirm they remain operational.
## Acceptance Criteria
- [ ] `prometeu-lsp` is no longer part of the build graph.
- [ ] `prometeu-vfs` is no longer part of the build graph.
- [ ] No production Studio class imports legacy `LSP` or `VFS` module types.
- [ ] Compiler and shipper build flows still work.
- [ ] Play/run/debug flows still work without the removed modules.
## Dependencies
- `DEC-0031` accepted and normatively locked.
- Shell/session cleanup completed enough that no surviving production code still requires legacy `LSP`/`VFS` types.
## Risks
- Hidden transitive dependencies may keep the build graph coupled to removed modules longer than expected.
- Runtime or debug flows may have indirect event or session assumptions not obvious from imports alone.
- Deleting modules too early may obscure compile errors that should instead be fixed in surviving Studio code first.

View File

@ -0,0 +1,91 @@
---
id: PLN-0064
ticket: studio-editor-workspace-cleanup
title: Normative Test and Lesson Cleanup for Editor Stack Removal
status: open
created: 2026-05-05
completed:
tags: [studio, editor, cleanup, docs, tests, lessons]
---
## Objective
Remove or revise the normative documentation, tests, lessons, and user-facing resources that describe the embedded editor stack as a current Studio surface.
## Background
`DEC-0031` treats the editor stack removal as both code cleanup and normative cleanup. The repository currently contains active specs, README entries, i18n resources, and lessons that define `Code Editor`, `prometeu-vfs`, and the integrated semantic-read phase as active architecture.
## Scope
### Included
- Remove or rewrite Studio specs dedicated to the embedded editor stack.
- Update the Studio specs index and README.
- Remove or archive editor/LSP/VFS-specific tests that are no longer valid.
- Reclassify legacy lessons and references so they are not presented as active guidance.
- Remove user-facing i18n strings that only existed for the editor stack.
### Excluded
- Implementation of shell/session/module cleanup itself.
- Future specs for the replacement backend or replacement external-editor strategy.
## Execution Steps
### Step 1 - Clean normative Studio specs
**What:** Remove or replace specs that define the embedded editor stack as active architecture.
**How:** Delete or explicitly retire the `Code Editor`, `Project Document VFS`, and `Integrated LSP Semantic Read Phase` specs, then rewrite any remaining Studio README/index material so it no longer points to dead architecture.
**File(s):** `docs/specs/studio/5. Code Editor Workspace Specification.md`, `docs/specs/studio/6. Project Document VFS Specification.md`, `docs/specs/studio/7. Integrated LSP Semantic Read Phase Specification.md`, `docs/specs/studio/README.md`.
### Step 2 - Clean user-facing resources and shell references
**What:** Remove editor-specific strings, labels, and messages that no longer correspond to product surfaces.
**How:** Delete `workspace.code` and `codeEditor.*` message keys and any equivalent theme or resource references still tied to removed editor UI.
**File(s):** `prometeu-studio/src/main/resources/i18n/messages.properties`, any directly related theme or resource files.
### Step 3 - Clean tests and update surviving expectations
**What:** Remove tests whose sole purpose was validating the editor stack and update remaining tests to stop expecting that stack.
**How:** Delete or rewrite tests for editor workspace behavior, legacy `LSP`, legacy `VFS`, session expectations, and any shell assertions that still require the editor workspace.
**File(s):** `prometeu-studio/src/test/java/p/studio/projectsessions/**`, editor-related tests, `prometeu-lsp/**/test/**`, `prometeu-vfs/**/test/**`, any shell-level tests affected by workspace removal.
### Step 4 - Reclassify lessons and historical references
**What:** Ensure discussion lessons remain historically useful without being mistaken for current architecture.
**How:** Review the lessons and references tied to editor/VFS/LSP waves and either archive them, mark them as legacy, or update index/readme material so they are not treated as active operational guidance.
**File(s):** affected files under `discussion/lessons/DSC-0010*`, `DSC-0012*`, `DSC-0013*`, `DSC-0014*`, `DSC-0016*`, `DSC-0017*`, `DSC-0020*`, `DSC-0021*`, plus any summary/index surfaces that present them.
## Test Requirements
### Unit Tests
- Ensure no surviving test suite still references removed editor stack types or message keys.
- Add assertions where needed that remaining shell/resource state is internally consistent.
### Integration Tests
- Validate the Studio docs/spec tree remains internally linked after removal or retirement of the editor specs.
- Validate the build/test graph no longer includes editor-stack-only suites.
### Manual Verification
- Read the Studio spec README and confirm it no longer advertises the removed stack as active.
- Inspect project lessons/readme surfaces and confirm editor-stack lessons are clearly historical or retired.
- Launch the application and confirm no missing i18n keys or dead UI labels remain.
## Acceptance Criteria
- [ ] Studio specs no longer define the embedded editor stack as an active surface.
- [ ] Studio README/index material no longer points to removed editor/VFS/LSP architecture as current.
- [ ] Editor-specific i18n keys are removed or no longer consumed.
- [ ] Obsolete tests are removed or rewritten.
- [ ] Historical lessons are clearly demoted from active operational guidance.
## Dependencies
- `DEC-0031` accepted and normatively locked.
- Sufficient implementation progress that the codebase no longer depends on the removed specs/resources/tests.
## Risks
- Removing normative docs too early may temporarily leave the repo without clear current guidance.
- Historical lessons may still be valuable; deleting them outright could lose context that should instead be reclassified.
- Partial cleanup can leave broken links or missing i18n keys that only appear at runtime.

View File

@ -2,14 +2,10 @@ package p.studio;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.packer.Packer;
import p.studio.lsp.LspServiceFactory;
import p.studio.lsp.LspServiceFactoryImpl;
import p.studio.lsp.events.StudioEventBus;
import p.studio.lsp.events.StudioPackerEventAdapter;
import p.studio.utilities.ThemeService;
import p.studio.utilities.i18n.I18nService;
import p.studio.vfs.FilesystemProjectDocumentVfsFactory;
import p.studio.vfs.ProjectDocumentVfsFactory;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@ -21,8 +17,6 @@ public final class AppContainer implements Container {
private final ThemeService themeService;
private final StudioEventBus studioEventBus;
private final ObjectMapper mapper;
private final LspServiceFactory lspServiceFactory;
private final ProjectDocumentVfsFactory projectDocumentVfsFactory;
private final EmbeddedPacker embeddedPacker;
private final StudioBackgroundTasks backgroundTasks;
@ -31,8 +25,6 @@ public final class AppContainer implements Container {
this.themeService = new ThemeService();
this.studioEventBus = new StudioEventBus();
this.mapper = new ObjectMapper();
this.lspServiceFactory = new LspServiceFactoryImpl();
this.projectDocumentVfsFactory = new FilesystemProjectDocumentVfsFactory();
final ExecutorService backgroundExecutor = Executors.newFixedThreadPool(2, new StudioWorkerThreadFactory());
this.backgroundTasks = new StudioBackgroundTasks(backgroundExecutor);
final Packer packer = Packer.bootstrap(this.mapper, new StudioPackerEventAdapter(studioEventBus));
@ -59,16 +51,6 @@ public final class AppContainer implements Container {
return mapper;
}
@Override
public LspServiceFactory getPrometeuLspServiceFactory() {
return lspServiceFactory;
}
@Override
public ProjectDocumentVfsFactory getProjectDocumentVfsFactory() {
return projectDocumentVfsFactory;
}
@Override
public EmbeddedPacker getPacker() {
return embeddedPacker;

View File

@ -1,11 +1,9 @@
package p.studio;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.studio.lsp.LspServiceFactory;
import p.studio.lsp.events.StudioEventBus;
import p.studio.utilities.ThemeService;
import p.studio.utilities.i18n.I18nService;
import p.studio.vfs.ProjectDocumentVfsFactory;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
@ -19,10 +17,6 @@ public interface Container {
ObjectMapper getMapper();
LspServiceFactory getPrometeuLspServiceFactory();
ProjectDocumentVfsFactory getProjectDocumentVfsFactory();
EmbeddedPacker getPacker();
StudioBackgroundTasks getBackgroundTasks();
@ -66,14 +60,6 @@ public interface Container {
return current().getMapper();
}
static LspServiceFactory prometeuLspServiceFactory() {
return current().getPrometeuLspServiceFactory();
}
static ProjectDocumentVfsFactory projectDocumentVfsFactory() {
return current().getProjectDocumentVfsFactory();
}
static EmbeddedPacker packer() {
return current().getPacker();
}

View File

@ -1,19 +1,15 @@
package p.studio.projectsessions;
import p.studio.execution.StudioExecutionSessionService;
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;
import p.studio.vfs.VfsProjectDocument;
import java.util.Objects;
public final class StudioProjectSession implements AutoCloseable {
private final ProjectReference projectReference;
private final LspService prometeuLspService;
private final VfsProjectDocument vfsProjectDocument;
private final ProjectLocalStudioStateService projectLocalStudioStateService;
private final ProjectLocalStudioSetup projectLocalStudioSetup;
private final StudioExecutionSessionService executionSessionService;
@ -21,13 +17,9 @@ public final class StudioProjectSession implements AutoCloseable {
private boolean closed;
public StudioProjectSession(
final ProjectReference projectReference,
final LspService prometeuLspService,
final VfsProjectDocument vfsProjectDocument) {
final ProjectReference projectReference) {
this(
projectReference,
prometeuLspService,
vfsProjectDocument,
new ProjectLocalStudioStateService(),
ProjectLocalStudioSetup.defaults(),
new StudioExecutionSessionService(),
@ -36,15 +28,11 @@ public final class StudioProjectSession implements AutoCloseable {
StudioProjectSession(
final ProjectReference projectReference,
final LspService prometeuLspService,
final VfsProjectDocument vfsProjectDocument,
final ProjectLocalStudioStateService projectLocalStudioStateService,
final ProjectLocalStudioSetup projectLocalStudioSetup,
final StudioExecutionSessionService executionSessionService,
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.executionSessionService = Objects.requireNonNull(executionSessionService, "executionSessionService");
@ -55,14 +43,6 @@ public final class StudioProjectSession implements AutoCloseable {
return projectReference;
}
public VfsProjectDocument projectDocumentVfs() {
return vfsProjectDocument;
}
public LspService prometeuLspService() {
return prometeuLspService;
}
public ProjectLocalStudioState projectLocalStudioState() {
return projectLocalStudioState;
}
@ -96,25 +76,13 @@ public final class StudioProjectSession implements AutoCloseable {
failure = runtimeException;
}
try {
prometeuLspService.close();
} catch (RuntimeException runtimeException) {
if (failure == null) {
failure = runtimeException;
} else {
failure.addSuppressed(runtimeException);
if (failure != null) {
throw failure;
}
}
try {
vfsProjectDocument.close();
} catch (RuntimeException runtimeException) {
if (failure == null) {
failure = runtimeException;
} else {
failure.addSuppressed(runtimeException);
} finally {
if (failure != null) {
throw failure;
}
}
if (failure != null) {
throw failure;
}
}
}

View File

@ -1,60 +1,36 @@
package p.studio.projectsessions;
import p.studio.execution.StudioExecutionSessionService;
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;
import p.studio.vfs.ProjectDocumentVfsFactory;
import java.util.Objects;
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) {
public StudioProjectSessionFactory() {
this(
lspServiceFactory,
projectDocumentVfsFactory,
new ProjectLocalStudioStateService(),
new ProjectLocalStudioSetupService());
}
StudioProjectSessionFactory(
final LspServiceFactory lspServiceFactory,
final ProjectDocumentVfsFactory projectDocumentVfsFactory,
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) {
final ProjectReference target = Objects.requireNonNull(projectReference, "projectReference");
final VfsProjectDocument vfsProjectDocument = projectDocumentVfsFactory.open(target.toVfsProjectContext());
return new StudioProjectSession(
target,
lspServiceFactory.open(lspProjectContext(target), vfsProjectDocument),
vfsProjectDocument,
projectLocalStudioStateService,
projectLocalStudioSetupService.load(target),
new StudioExecutionSessionService(),
projectLocalStudioStateService.load(target));
}
private LspProjectContext lspProjectContext(final ProjectReference projectReference) {
return new LspProjectContext(
projectReference.name(),
projectReference.languageId(),
projectReference.rootPath());
}
}

View File

@ -3,57 +3,48 @@ package p.studio.projectstate;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ProjectLocalStudioState(
int version,
ShellLayout shellLayout,
OpenShellState openShellState,
EditorRestorationState editorRestoration) {
OpenShellState openShellState) {
public static final int CURRENT_VERSION = 1;
public ProjectLocalStudioState {
version = version <= 0 ? CURRENT_VERSION : version;
shellLayout = shellLayout == null ? ShellLayout.defaults() : shellLayout.normalize();
openShellState = openShellState == null ? OpenShellState.defaults() : openShellState.normalize();
editorRestoration = editorRestoration == null ? EditorRestorationState.defaults() : editorRestoration.normalize();
}
public static ProjectLocalStudioState defaults() {
return new ProjectLocalStudioState(
CURRENT_VERSION,
ShellLayout.defaults(),
OpenShellState.defaults(),
EditorRestorationState.defaults());
OpenShellState.defaults());
}
public ProjectLocalStudioState withShellLayout(final ShellLayout nextShellLayout) {
return new ProjectLocalStudioState(version, nextShellLayout, openShellState, editorRestoration);
return new ProjectLocalStudioState(version, nextShellLayout, openShellState);
}
public ProjectLocalStudioState withOpenShellState(final OpenShellState nextOpenShellState) {
return new ProjectLocalStudioState(version, shellLayout, nextOpenShellState, editorRestoration);
}
public ProjectLocalStudioState withEditorRestoration(final EditorRestorationState nextEditorRestoration) {
return new ProjectLocalStudioState(version, shellLayout, openShellState, nextEditorRestoration);
return new ProjectLocalStudioState(version, shellLayout, nextOpenShellState);
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ShellLayout(EditorLayoutState editorLayout, AssetsLayoutState assetsLayout) {
public record ShellLayout(AssetsLayoutState assetsLayout) {
public ShellLayout {
editorLayout = editorLayout == null ? EditorLayoutState.defaults() : editorLayout.normalize();
assetsLayout = assetsLayout == null ? AssetsLayoutState.defaults() : assetsLayout.normalize();
}
public static ShellLayout defaults() {
return new ShellLayout(EditorLayoutState.defaults(), AssetsLayoutState.defaults());
return new ShellLayout(AssetsLayoutState.defaults());
}
private ShellLayout normalize() {
return new ShellLayout(editorLayout, assetsLayout);
return new ShellLayout(assetsLayout);
}
}
@ -76,6 +67,9 @@ public record ProjectLocalStudioState(
public record OpenShellState(String selectedWorkspaceId) {
public OpenShellState {
selectedWorkspaceId = normalizeText(selectedWorkspaceId);
if ("EDITOR".equals(selectedWorkspaceId)) {
selectedWorkspaceId = "ASSETS";
}
}
public static OpenShellState defaults() {
@ -87,80 +81,6 @@ public record ProjectLocalStudioState(
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record EditorLayoutState(
List<Double> contentSplitDividers,
List<Double> leftColumnDividers,
List<Double> rightColumnDividers,
DockPanelState outlinePanel,
DockPanelState helperPanel,
List<String> navigatorExpandedPaths) {
public EditorLayoutState {
contentSplitDividers = normalizeDividerPositions(contentSplitDividers);
leftColumnDividers = normalizeDividerPositions(leftColumnDividers);
rightColumnDividers = normalizeDividerPositions(rightColumnDividers);
outlinePanel = outlinePanel == null ? DockPanelState.defaults() : outlinePanel.normalize();
helperPanel = helperPanel == null ? DockPanelState.defaults() : helperPanel.normalize();
navigatorExpandedPaths = normalizePaths(navigatorExpandedPaths);
}
public static EditorLayoutState defaults() {
return new EditorLayoutState(
List.of(),
List.of(),
List.of(),
DockPanelState.defaults(),
DockPanelState.defaults(),
List.of());
}
private EditorLayoutState normalize() {
return new EditorLayoutState(
contentSplitDividers,
leftColumnDividers,
rightColumnDividers,
outlinePanel,
helperPanel,
navigatorExpandedPaths);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record DockPanelState(boolean expanded, Double dividerPosition) {
public DockPanelState {
dividerPosition = normalizeDividerPosition(dividerPosition);
}
public static DockPanelState defaults() {
return new DockPanelState(true, null);
}
private DockPanelState normalize() {
return new DockPanelState(expanded, dividerPosition);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record EditorRestorationState(List<String> openTabPaths, String activeTabPath) {
public EditorRestorationState {
openTabPaths = normalizePaths(openTabPaths);
activeTabPath = normalizeText(activeTabPath);
if (activeTabPath != null && !openTabPaths.contains(activeTabPath)) {
final List<String> expanded = new ArrayList<>(openTabPaths);
expanded.add(activeTabPath);
openTabPaths = List.copyOf(expanded);
}
}
public static EditorRestorationState defaults() {
return new EditorRestorationState(List.of(), null);
}
private EditorRestorationState normalize() {
return new EditorRestorationState(openTabPaths, activeTabPath);
}
}
private static String normalizeText(final String value) {
if (value == null) {
return null;
@ -169,20 +89,6 @@ public record ProjectLocalStudioState(
return trimmed.isEmpty() ? null : trimmed;
}
private static List<String> normalizePaths(final List<String> values) {
if (values == null || values.isEmpty()) {
return List.of();
}
final LinkedHashSet<String> normalized = new LinkedHashSet<>();
for (final String value : values) {
final String text = normalizeText(value);
if (text != null) {
normalized.add(text);
}
}
return List.copyOf(normalized);
}
private static List<Double> normalizeDividerPositions(final List<Double> values) {
if (values == null || values.isEmpty()) {
return List.of();
@ -196,11 +102,4 @@ public record ProjectLocalStudioState(
}
return List.copyOf(normalized);
}
private static Double normalizeDividerPosition(final Double value) {
if (value == null || value.isNaN() || value.isInfinite()) {
return null;
}
return Math.max(0.0d, Math.min(1.0d, value));
}
}

View File

@ -15,7 +15,6 @@ import p.studio.workspaces.WorkspaceId;
import p.studio.workspaces.assets.AssetWorkspace;
import p.studio.workspaces.builder.ShipperWorkspace;
import p.studio.workspaces.debug.DebugWorkspace;
import p.studio.workspaces.editor.EditorWorkspace;
import java.util.List;
@ -24,7 +23,6 @@ public final class MainView extends BorderPane {
private final ProjectReference projectReference;
private final StudioProjectSession projectSession;
private final AssetWorkspace assetWorkspace;
private final EditorWorkspace editorWorkspace;
private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail;
private final StudioPlayStopCoordinator playStopCoordinator;
private boolean initializing;
@ -50,23 +48,14 @@ public final class MainView extends BorderPane {
assetWorkspace = new AssetWorkspace(projectReference);
host.register(assetWorkspace);
editorWorkspace = new EditorWorkspace(
projectReference,
projectSession.projectDocumentVfs(),
projectSession.prometeuLspService(),
projectSetup.editorIndentation());
host.register(editorWorkspace);
assetWorkspace.restoreProjectLocalState(persistedState);
editorWorkspace.restoreProjectLocalState(persistedState);
assetWorkspace.setStateChangedAction(this::persistProjectLocalState);
editorWorkspace.setStateChangedAction(this::persistProjectLocalState);
host.register(new DebugWorkspace(projectReference, projectSession.executionSession()));
host.register(new ShipperWorkspace(projectReference));
workspaceRail = new StudioWorkspaceRailControl<>(
List.of(
new StudioWorkspaceRailItem<>(WorkspaceId.ASSETS, "📦", Container.i18n().bind(I18n.WORKSPACE_ASSETS)),
new StudioWorkspaceRailItem<>(WorkspaceId.EDITOR, "📝", Container.i18n().bind(I18n.WORKSPACE_CODE)),
new StudioWorkspaceRailItem<>(WorkspaceId.DEBUG, "🎮", Container.i18n().bind(I18n.WORKSPACE_DEBUG)),
new StudioWorkspaceRailItem<>(WorkspaceId.SHIPPER, "⚙️", Container.i18n().bind(I18n.WORKSPACE_SHIPPER))
),
@ -102,14 +91,11 @@ public final class MainView extends BorderPane {
projectSession.replaceProjectLocalStudioState(
current.withOpenShellState(new ProjectLocalStudioState.OpenShellState(selectedWorkspace.name()))
.withShellLayout(new ProjectLocalStudioState.ShellLayout(
editorWorkspace.captureLayoutState(),
assetWorkspace.captureLayoutState()))
.withEditorRestoration(editorWorkspace.captureRestorationState()));
assetWorkspace.captureLayoutState())));
}
public void reapplyProjectLocalLayout() {
assetWorkspace.reapplyPendingLayoutState();
editorWorkspace.reapplyPendingLayoutState();
}
public void close() {

View File

@ -37,9 +37,7 @@ public final class StudioWindowCoordinator {
this.projectCatalogService = new ProjectCatalogService(resolveDefaultProjectsRoot());
this.knownProjectsService = new KnownProjectsService(resolveKnownProjectsStorage(), projectCatalogService);
this.windowStateService = new WindowStateService(resolveWindowStateStorage());
this.projectSessionFactory = new StudioProjectSessionFactory(
Container.prometeuLspServiceFactory(),
Container.projectDocumentVfsFactory());
this.projectSessionFactory = new StudioProjectSessionFactory();
this.launcherView = new ProjectLauncherView(
knownProjectsService,
projectCatalogService,

View File

@ -1,7 +1,6 @@
package p.studio.workspaces;
public enum WorkspaceId {
EDITOR,
ASSETS,
SHIPPER,
DEBUG

View File

@ -1,6 +0,0 @@
package p.studio.workspaces.editor;
enum EditorDocumentHighlightOwner {
LOCAL,
LSP
}

View File

@ -1,20 +0,0 @@
package p.studio.workspaces.editor;
import org.fxmisc.richtext.model.StyleSpans;
import p.studio.lsp.dtos.LspInlineHintDTO;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
record EditorDocumentHighlightingResult(
EditorDocumentHighlightOwner owner,
StyleSpans<Collection<String>> styleSpans,
List<LspInlineHintDTO> inlineHints) {
EditorDocumentHighlightingResult {
owner = Objects.requireNonNull(owner, "owner");
styleSpans = Objects.requireNonNull(styleSpans, "styleSpans");
inlineHints = List.copyOf(Objects.requireNonNull(inlineHints, "inlineHints"));
}
}

View File

@ -1,32 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
import p.studio.workspaces.editor.syntaxhighlight.EditorDocumentSemanticHighlighting;
final class EditorDocumentHighlightingRouter {
private EditorDocumentHighlightingRouter() {
}
static EditorDocumentHighlightingResult route(
final EditorOpenFileBuffer fileBuffer,
final EditorDocumentPresentation presentation,
final LspAnalyzeDocumentResult analysis) {
final var localHighlighting = presentation.highlight(fileBuffer.content());
final var inlineHints = EditorDocumentInlineHintRouter.route(fileBuffer, analysis);
if (fileBuffer.frontendDocument()
&& analysis != null
&& presentation.supportsSemanticHighlighting()
&& !analysis.semanticHighlights().isEmpty()) {
return new EditorDocumentHighlightingResult(
EditorDocumentHighlightOwner.LSP,
EditorDocumentSemanticHighlighting.overlay(
localHighlighting,
EditorDocumentSemanticHighlighting.highlight(fileBuffer.content(), analysis.semanticHighlights())),
inlineHints);
}
return new EditorDocumentHighlightingResult(
EditorDocumentHighlightOwner.LOCAL,
localHighlighting,
inlineHints);
}
}

View File

@ -1,20 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.lsp.dtos.LspInlineHintDTO;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
import java.util.List;
final class EditorDocumentInlineHintRouter {
private EditorDocumentInlineHintRouter() {
}
static List<LspInlineHintDTO> route(
final EditorOpenFileBuffer fileBuffer,
final LspAnalyzeDocumentResult analysis) {
if (!fileBuffer.frontendDocument() || analysis == null || analysis.inlineHints().isEmpty()) {
return List.of();
}
return analysis.inlineHints();
}
}

View File

@ -1,30 +0,0 @@
package p.studio.workspaces.editor;
import org.fxmisc.richtext.model.StyleSpans;
import p.studio.workspaces.editor.syntaxhighlight.EditorDocumentSyntaxHighlighting;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
record EditorDocumentPresentation(
String styleKey,
List<String> stylesheetUrls,
List<String> semanticKeys,
EditorDocumentSyntaxHighlighting syntaxHighlighting) {
EditorDocumentPresentation {
styleKey = Objects.requireNonNull(styleKey, "styleKey");
stylesheetUrls = List.copyOf(Objects.requireNonNull(stylesheetUrls, "stylesheetUrls"));
semanticKeys = List.copyOf(Objects.requireNonNull(semanticKeys, "semanticKeys"));
syntaxHighlighting = Objects.requireNonNull(syntaxHighlighting, "syntaxHighlighting");
}
StyleSpans<Collection<String>> highlight(final String content) {
return syntaxHighlighting.highlight(content);
}
boolean supportsSemanticHighlighting() {
return !semanticKeys.isEmpty() && !stylesheetUrls.isEmpty();
}
}

View File

@ -1,122 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.compiler.FrontendRegistryService;
import p.studio.compiler.models.FrontendSemanticPresentationSpec;
import p.studio.lsp.dtos.LspSemanticPresentationDTO;
import p.studio.vfs.messages.VfsDocumentTypeIds;
import p.studio.workspaces.editor.syntaxhighlight.EditorDocumentSyntaxHighlighting;
import java.net.URL;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Logger;
final class EditorDocumentPresentationRegistry {
private static final Logger LOGGER = Logger.getLogger(EditorDocumentPresentationRegistry.class.getName());
private static final EditorDocumentPresentation TEXT_PRESENTATION = new EditorDocumentPresentation(
"text",
java.util.List.of(),
java.util.List.of(),
EditorDocumentSyntaxHighlighting.plainText());
private static final EditorDocumentPresentation JSON_PRESENTATION = new EditorDocumentPresentation(
"json",
java.util.List.of(stylesheet("presentations/json.css")),
java.util.List.of(),
EditorDocumentSyntaxHighlighting.json());
private static final EditorDocumentPresentation BASH_PRESENTATION = new EditorDocumentPresentation(
"bash",
java.util.List.of(stylesheet("presentations/bash.css")),
java.util.List.of(),
EditorDocumentSyntaxHighlighting.bash());
EditorDocumentPresentation resolve(final String typeId) {
return resolve(typeId, null);
}
EditorDocumentPresentation resolve(
final String typeId,
final LspSemanticPresentationDTO semanticPresentation) {
final String normalizedTypeId = normalize(typeId);
if (normalizedTypeId.isBlank()) {
return TEXT_PRESENTATION;
}
if (FrontendRegistryService.getFrontendSpec(normalizedTypeId).isPresent()) {
return frontendPresentation(normalizedTypeId, semanticPresentation);
}
if (VfsDocumentTypeIds.JSON.equals(normalizedTypeId)) {
return JSON_PRESENTATION;
}
if (VfsDocumentTypeIds.BASH.equals(normalizedTypeId)) {
return BASH_PRESENTATION;
}
return TEXT_PRESENTATION;
}
private EditorDocumentPresentation frontendPresentation(
final String normalizedTypeId,
final LspSemanticPresentationDTO semanticPresentation) {
final var frontendSpec = FrontendRegistryService.getFrontendSpec(normalizedTypeId).orElseThrow();
final FrontendSemanticPresentationSpec frontendPresentationSpec = frontendSpec.getSemanticPresentation();
final java.util.List<String> stylesheetUrls = resolveFrontendStylesheets(
semanticPresentation == null ? java.util.List.of() : semanticPresentation.resources(),
frontendPresentationSpec.resources());
final java.util.List<String> semanticKeys = semanticPresentation == null
? frontendPresentationSpec.semanticKeys()
: semanticPresentation.semanticKeys();
return new EditorDocumentPresentation(
normalizedTypeId,
stylesheetUrls,
semanticKeys,
frontendSyntaxHighlighting(normalizedTypeId));
}
private String normalize(final String typeId) {
return typeId == null ? "" : typeId.trim().toLowerCase(Locale.ROOT);
}
private static String stylesheet(final String relativePath) {
return java.util.Objects.requireNonNull(
EditorDocumentPresentationRegistry.class.getResource("/themes/editor/" + relativePath),
"missing editor presentation stylesheet: " + relativePath)
.toExternalForm();
}
private static Optional<String> resourceStylesheet(final String resourcePath) {
final String normalizedPath = normalizeResourcePath(resourcePath);
final URL resource = EditorDocumentPresentationRegistry.class.getResource(normalizedPath);
if (resource == null) {
LOGGER.fine("missing frontend semantic presentation resource: " + resourcePath);
return Optional.empty();
}
return Optional.of(resource.toExternalForm());
}
private static java.util.List<String> resolveFrontendStylesheets(
final java.util.List<String> primaryResources,
final java.util.List<String> fallbackResources) {
final java.util.List<String> primary = primaryResources.stream()
.map(EditorDocumentPresentationRegistry::resourceStylesheet)
.flatMap(Optional::stream)
.toList();
if (!primary.isEmpty()) {
return primary;
}
return fallbackResources.stream()
.map(EditorDocumentPresentationRegistry::resourceStylesheet)
.flatMap(Optional::stream)
.toList();
}
private static EditorDocumentSyntaxHighlighting frontendSyntaxHighlighting(final String normalizedTypeId) {
return EditorDocumentSyntaxHighlighting.plainText();
}
private static String normalizeResourcePath(final String resourcePath) {
final String normalized = Objects.requireNonNull(resourcePath, "resourcePath").trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("resourcePath cannot be blank");
}
return normalized.startsWith("/") ? normalized : "/" + normalized;
}
}

View File

@ -1,28 +0,0 @@
package p.studio.workspaces.editor;
import javafx.scene.Node;
import javafx.scene.control.Label;
final class EditorDocumentPresentationStyles {
private static final String CODE_AREA_TYPE_CLASS_PREFIX = "editor-workspace-code-area-type-";
private static final String STATUS_CHIP_TYPE_CLASS_PREFIX = "editor-workspace-status-chip-type-";
private EditorDocumentPresentationStyles() {
}
static void applyToCodeArea(final Node node, final EditorDocumentPresentation presentation) {
replaceTypeClass(node, CODE_AREA_TYPE_CLASS_PREFIX, presentation.styleKey());
}
static void applyToStatusChip(final Label label, final EditorDocumentPresentation presentation) {
replaceTypeClass(label, STATUS_CHIP_TYPE_CLASS_PREFIX, presentation.styleKey());
}
private static void replaceTypeClass(
final Node node,
final String prefix,
final String styleKey) {
node.getStyleClass().removeIf(styleClass -> styleClass.startsWith(prefix));
node.getStyleClass().add(prefix + styleKey);
}
}

View File

@ -1,91 +0,0 @@
package p.studio.workspaces.editor;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
final class EditorDocumentScopeGuideGraphicFactory {
private static final double COLUMN_WIDTH = 10.0;
private static final double CANVAS_PADDING = 6.0;
private static final double CONTAINER_X = CANVAS_PADDING + (COLUMN_WIDTH / 2.0);
private static final double SCOPE_X = CANVAS_PADDING + COLUMN_WIDTH + (COLUMN_WIDTH / 2.0);
private static final double GUIDE_CAP = 4.0;
private static final Color CONTAINER_COLOR = Color.web("#5f7896");
private static final Color SCOPE_COLOR = Color.web("#8f72ff");
private EditorDocumentScopeGuideGraphicFactory() {
}
static Node create(
final Node lineNumberNode,
final int paragraphIndex,
final EditorDocumentScopeGuideModel model,
final EditorDocumentScopeGuideModel.ActiveGuides activeGuides) {
final var container = new HBox(lineNumberNode);
final var segments = model.segmentsForLine(paragraphIndex, activeGuides);
container.getChildren().add(new ScopeGuideCanvas(segments));
return container;
}
private static final class ScopeGuideCanvas extends Region {
private final Canvas canvas = new Canvas();
private final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments;
private ScopeGuideCanvas(
final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments) {
this.segments = segments;
getStyleClass().add("editor-workspace-scope-guide-gutter");
getChildren().add(canvas);
setMinWidth(computeGuideWidth());
setPrefWidth(computeGuideWidth());
setMaxWidth(computeGuideWidth());
}
@Override
protected double computePrefWidth(final double height) {
return computeGuideWidth();
}
@Override
protected void layoutChildren() {
final double width = snapSizeX(getWidth());
final double height = Math.max(1.0, snapSizeY(getHeight()));
canvas.setWidth(width);
canvas.setHeight(height);
draw(width, height);
}
private void draw(final double width, final double height) {
final GraphicsContext graphics = canvas.getGraphicsContext2D();
graphics.clearRect(0, 0, width, height);
if (segments.isEmpty()) {
return;
}
final double centerY = Math.floor(height / 2.0);
for (final var segment : segments) {
final boolean container = segment.role() == EditorDocumentScopeGuideModel.GuideRole.ACTIVE_CONTAINER;
final double x = container ? CONTAINER_X : SCOPE_X;
graphics.setLineWidth(container ? 1.0 : 1.4);
graphics.setStroke(container ? CONTAINER_COLOR : SCOPE_COLOR);
switch (segment.kind()) {
case START -> {
graphics.strokeLine(x, centerY, x, height);
graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
}
case CONTINUE -> graphics.strokeLine(x, 0, x, height);
case END -> {
graphics.strokeLine(x, 0, x, centerY);
graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
}
}
}
}
private static double computeGuideWidth() {
return CANVAS_PADDING * 2.0 + (COLUMN_WIDTH * 2.0);
}
}
}

View File

@ -1,340 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.lsp.dtos.LspRangeDTO;
import p.studio.lsp.dtos.LspStructuralAnchorDTO;
import p.studio.lsp.dtos.LspSymbolDTO;
import p.studio.lsp.messages.LspSymbolKind;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
final class EditorDocumentScopeGuideModel {
private static final EditorDocumentScopeGuideModel EMPTY =
new EditorDocumentScopeGuideModel(List.of(), List.of(0), 0);
private final List<GuideRange> ranges;
private final List<Integer> lineStarts;
private final int contentLength;
private EditorDocumentScopeGuideModel(
final List<GuideRange> ranges,
final List<Integer> lineStarts,
final int contentLength) {
this.ranges = ranges;
this.lineStarts = lineStarts;
this.contentLength = contentLength;
}
static EditorDocumentScopeGuideModel empty() {
return EMPTY;
}
static EditorDocumentScopeGuideModel from(
final String content,
final List<LspSymbolDTO> symbols) {
return fromRanges(
content,
symbols,
0,
null,
(symbol, depth, parentId, nextId, lineStarts, contentLength) -> {
if (symbol.kind() == LspSymbolKind.UNKNOWN) {
return null;
}
final LspRangeDTO range = symbol.range();
return toGuideRange(
lineStarts,
contentLength,
range,
new LspRangeDTO(range.startOffset(), Math.min(range.endOffset(), range.startOffset() + 1)),
new LspRangeDTO(Math.max(range.startOffset(), range.endOffset() - 1), range.endOffset()),
depth,
parentId,
nextId[0]);
},
LspSymbolDTO::children);
}
static EditorDocumentScopeGuideModel fromStructuralAnchors(
final String content,
final List<LspStructuralAnchorDTO> structuralAnchors) {
return fromRanges(
content,
structuralAnchors,
0,
null,
(anchor, depth, parentId, nextId, lineStarts, contentLength) -> toGuideRange(
lineStarts,
contentLength,
anchor.range(),
anchor.startAnchor(),
anchor.endAnchor(),
depth,
parentId,
nextId[0]),
LspStructuralAnchorDTO::children);
}
private static <T> EditorDocumentScopeGuideModel fromRanges(
final String content,
final List<T> roots,
final int initialDepth,
final Integer initialParentId,
final RangeFactory<T> rangeFactory,
final ChildAccessor<T> childAccessor) {
final List<Integer> lineStarts = lineStarts(content);
final List<GuideRange> ranges = new ArrayList<>();
final int[] nextId = new int[] {1};
for (final T root : roots) {
appendRangeNode(
ranges,
lineStarts,
content.length(),
root,
initialDepth,
initialParentId,
nextId,
rangeFactory,
childAccessor);
}
ranges.sort(Comparator
.comparingInt(GuideRange::startOffset)
.thenComparingInt(GuideRange::depth)
.thenComparingInt(GuideRange::endOffset));
return new EditorDocumentScopeGuideModel(List.copyOf(ranges), lineStarts, content.length());
}
ActiveGuides resolveActiveGuides(final int caretOffset) {
if (ranges.isEmpty()) {
return ActiveGuides.empty();
}
final int probeOffset = clampCaretOffset(caretOffset);
GuideRange activeScope = null;
for (final GuideRange range : ranges) {
if (!contains(range, probeOffset)) {
continue;
}
if (activeScope == null
|| span(range) < span(activeScope)
|| (span(range) == span(activeScope) && range.depth() > activeScope.depth())) {
activeScope = range;
}
}
if (activeScope == null) {
return ActiveGuides.empty();
}
final GuideRange activeContainer = activeScope.parentId() == null
? null
: findById(activeScope.parentId());
return new ActiveGuides(activeContainer, activeScope);
}
List<ActiveGuideSegment> segmentsForLine(
final int lineIndex,
final ActiveGuides activeGuides) {
if (lineIndex < 0 || lineIndex >= totalLines() || activeGuides.isEmpty()) {
return List.of();
}
final List<ActiveGuideSegment> segments = new ArrayList<>(2);
appendSegments(segments, lineIndex, activeGuides.activeContainer(), GuideRole.ACTIVE_CONTAINER);
appendSegments(segments, lineIndex, activeGuides.activeScope(), GuideRole.ACTIVE_SCOPE);
return List.copyOf(segments);
}
int totalLines() {
return lineStarts.size();
}
private static <T> void appendRangeNode(
final List<GuideRange> ranges,
final List<Integer> lineStarts,
final int contentLength,
final T node,
final int depth,
final Integer parentId,
final int[] nextId,
final RangeFactory<T> rangeFactory,
final ChildAccessor<T> childAccessor) {
Integer currentParentId = parentId;
final GuideRange range = rangeFactory.create(node, depth, parentId, nextId, lineStarts, contentLength);
if (range != null) {
ranges.add(range);
currentParentId = range.id();
nextId[0]++;
}
for (final T child : childAccessor.children(node)) {
appendRangeNode(ranges, lineStarts, contentLength, child, depth + 1, currentParentId, nextId, rangeFactory, childAccessor);
}
}
private static GuideRange toGuideRange(
final List<Integer> lineStarts,
final int contentLength,
final LspRangeDTO range,
final LspRangeDTO startAnchor,
final LspRangeDTO endAnchor,
final int depth,
final Integer parentId,
final int id) {
final int startOffset = clamp(range.startOffset(), 0, contentLength);
final int endOffset = Math.max(startOffset, clamp(range.endOffset(), 0, contentLength));
final int startAnchorOffset = clamp(startAnchor.startOffset(), startOffset, endOffset);
final int endAnchorOffset = clamp(Math.max(startAnchorOffset, endAnchor.startOffset()), startOffset, endOffset);
final int startLine = lineForOffset(lineStarts, startAnchorOffset);
final int inclusiveEndOffset = Math.max(startOffset, Math.max(startOffset, endOffset) - 1);
final int inclusiveEndAnchorOffset = Math.max(
startAnchorOffset,
Math.min(Math.max(startOffset, endOffset) - 1, Math.max(endAnchorOffset, startAnchorOffset)));
final int endLine = lineForOffset(lineStarts, clamp(inclusiveEndAnchorOffset, 0, contentLength));
if (endLine <= startLine) {
return null;
}
return new GuideRange(id, depth, startOffset, endOffset, startAnchorOffset, endAnchorOffset, startLine, endLine, parentId);
}
private GuideRange findById(final int id) {
for (final GuideRange range : ranges) {
if (range.id() == id) {
return range;
}
}
return null;
}
private int clampCaretOffset(final int offset) {
if (contentLength <= 0) {
return 0;
}
return clamp(offset, 0, contentLength - 1);
}
private static boolean contains(final GuideRange range, final int offset) {
return offset >= range.startOffset() && offset < range.endOffset();
}
private static int span(final GuideRange range) {
return range.endOffset() - range.startOffset();
}
private static void appendSegments(
final List<ActiveGuideSegment> segments,
final int lineIndex,
final GuideRange range,
final GuideRole role) {
if (range == null || lineIndex < range.startLine() || lineIndex > range.endLine()) {
return;
}
final GuideSegmentKind kind;
if (lineIndex == range.startLine()) {
kind = GuideSegmentKind.START;
} else if (lineIndex == range.endLine()) {
kind = GuideSegmentKind.END;
} else {
kind = GuideSegmentKind.CONTINUE;
}
segments.add(new ActiveGuideSegment(role, kind));
}
private static List<Integer> lineStarts(final String content) {
if (content.isEmpty()) {
return List.of(0);
}
final List<Integer> starts = new ArrayList<>();
starts.add(0);
for (int index = 0; index < content.length(); index++) {
if (content.charAt(index) == '\n') {
starts.add(index + 1);
}
}
return Collections.unmodifiableList(starts);
}
private static int lineForOffset(final List<Integer> lineStarts, final int offset) {
int low = 0;
int high = lineStarts.size() - 1;
while (low <= high) {
final int mid = (low + high) >>> 1;
final int start = lineStarts.get(mid);
final int nextStart = mid + 1 < lineStarts.size() ? lineStarts.get(mid + 1) : Integer.MAX_VALUE;
if (offset < start) {
high = mid - 1;
continue;
}
if (offset >= nextStart) {
low = mid + 1;
continue;
}
return mid;
}
return Math.max(0, Math.min(lineStarts.size() - 1, low));
}
private static int clamp(final int value, final int minimum, final int maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
enum GuideRole {
ACTIVE_CONTAINER,
ACTIVE_SCOPE
}
enum GuideSegmentKind {
START,
CONTINUE,
END
}
record GuideRange(
int id,
int depth,
int startOffset,
int endOffset,
int startAnchorOffset,
int endAnchorOffset,
int startLine,
int endLine,
Integer parentId) {
}
record ActiveGuides(
GuideRange activeContainer,
GuideRange activeScope) {
private static final ActiveGuides EMPTY = new ActiveGuides(null, null);
static ActiveGuides empty() {
return EMPTY;
}
boolean isEmpty() {
return activeScope == null;
}
}
record ActiveGuideSegment(
GuideRole role,
GuideSegmentKind kind) {
ActiveGuideSegment {
Objects.requireNonNull(role, "role");
Objects.requireNonNull(kind, "kind");
}
}
@FunctionalInterface
private interface ChildAccessor<T> {
List<T> children(T node);
}
@FunctionalInterface
private interface RangeFactory<T> {
GuideRange create(
T node,
int depth,
Integer parentId,
int[] nextId,
List<Integer> lineStarts,
int contentLength);
}
}

View File

@ -1,96 +0,0 @@
package p.studio.workspaces.editor;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import p.studio.Container;
import p.studio.controls.WorkspaceDockPane;
import p.studio.lsp.dtos.LspDiagnosticDTO;
import p.studio.utilities.i18n.I18n;
import java.nio.file.Path;
import java.util.List;
public final class EditorHelperPanel extends WorkspaceDockPane {
public static final double COLLAPSED_HEIGHT = 34.0;
public static final double MINIMUM_EXPANDED_HEIGHT = 120.0;
public static final double DEFAULT_EXPANDED_HEIGHT = 180.0;
private final Label summary = new Label();
private final Label diagnosticsTitle = new Label();
private final VBox diagnosticsBox = new VBox(6);
public EditorHelperPanel() {
super(
Container.i18n().bind(I18n.CODE_EDITOR_HELPER_TITLE),
COLLAPSED_HEIGHT,
MINIMUM_EXPANDED_HEIGHT,
DEFAULT_EXPANDED_HEIGHT,
false,
"editor-workspace-helper-pane",
"editor-workspace-helper-panel");
summary.getStyleClass().addAll("editor-workspace-placeholder", "editor-workspace-helper-summary");
summary.setWrapText(true);
diagnosticsTitle.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_HELPER_DIAGNOSTICS));
diagnosticsTitle.getStyleClass().add("editor-workspace-outline-section-title");
diagnosticsBox.getStyleClass().add("editor-workspace-outline-list");
final var content = new VBox(10, summary, diagnosticsTitle, diagnosticsBox);
content.getStyleClass().add("editor-workspace-panel-content");
content.setPadding(new Insets(10, 16, 14, 16));
final var scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
scrollPane.getStyleClass().add("editor-workspace-outline-scroll");
setDockContent(scrollPane);
showPlaceholder();
}
public void showPlaceholder() {
summary.textProperty().unbind();
summary.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_HELPER_PLACEHOLDER));
diagnosticsBox.getChildren().setAll(placeholderLabel(I18n.CODE_EDITOR_HELPER_EMPTY_DIAGNOSTICS));
}
public void showSemanticReadResult(
final Path documentPath,
final List<LspDiagnosticDTO> diagnostics) {
summary.textProperty().unbind();
summary.setText(documentPath.getFileName() + " • semantic read-only");
rebuildDiagnostics(diagnostics);
}
private void rebuildDiagnostics(final List<LspDiagnosticDTO> diagnostics) {
diagnosticsBox.getChildren().clear();
if (diagnostics.isEmpty()) {
diagnosticsBox.getChildren().add(placeholderLabel(I18n.CODE_EDITOR_HELPER_EMPTY_DIAGNOSTICS));
return;
}
for (final LspDiagnosticDTO diagnostic : diagnostics) {
final var label = new Label(formatDiagnostic(diagnostic));
label.setWrapText(true);
label.getStyleClass().addAll(
"editor-workspace-outline-item",
diagnostic.severity().name().equals("ERROR")
? "editor-workspace-outline-diagnostic-error"
: "editor-workspace-outline-diagnostic-warning");
diagnosticsBox.getChildren().add(label);
}
}
private Label placeholderLabel(final I18n key) {
final var label = new Label();
label.textProperty().bind(Container.i18n().bind(key));
label.setWrapText(true);
label.getStyleClass().addAll("editor-workspace-placeholder", "editor-workspace-outline-item");
return label;
}
private String formatDiagnostic(final LspDiagnosticDTO diagnostic) {
return "%s [%d,%d) %s".formatted(
diagnostic.severity().name(),
diagnostic.range().startOffset(),
diagnostic.range().endOffset(),
diagnostic.message());
}
}

View File

@ -1,72 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.lsp.dtos.LspInlineHintDTO;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
final class EditorInlineHintLayout {
static final double HORIZONTAL_GAP = 6.0;
private EditorInlineHintLayout() {
}
static List<Placement> layout(
final List<LspInlineHintDTO> inlineHints,
final AnchorBoundsResolver anchorBoundsResolver) {
final var placements = new ArrayList<Placement>();
for (final var inlineHint : inlineHints) {
final var anchorBounds = anchorBoundsResolver.resolve(inlineHint);
if (anchorBounds.isEmpty()) {
continue;
}
final var bounds = anchorBounds.orElseThrow();
placements.add(new Placement(
inlineHint,
displayText(inlineHint),
categoryStyleClass(inlineHint),
bounds.maxX() + HORIZONTAL_GAP,
bounds.minY(),
bounds.height()));
}
return List.copyOf(placements);
}
static String displayText(final LspInlineHintDTO inlineHint) {
return ": %s".formatted(inlineHint.label());
}
static String categoryStyleClass(final LspInlineHintDTO inlineHint) {
final var normalizedCategory = inlineHint.category()
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+", "")
.replaceAll("-+$", "");
return normalizedCategory.isBlank()
? "editor-inline-hint-generic"
: "editor-inline-hint-" + normalizedCategory;
}
@FunctionalInterface
interface AnchorBoundsResolver {
Optional<AnchorBounds> resolve(LspInlineHintDTO inlineHint);
}
record AnchorBounds(
double minX,
double minY,
double maxX,
double height) {
}
record Placement(
LspInlineHintDTO inlineHint,
String displayText,
String categoryStyleClass,
double screenX,
double screenY,
double anchorHeight) {
}
}

View File

@ -1,124 +0,0 @@
package p.studio.workspaces.editor;
import javafx.application.Platform;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import org.fxmisc.richtext.CodeArea;
import org.reactfx.Subscription;
import p.studio.lsp.dtos.LspInlineHintDTO;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
final class EditorInlineHintOverlay {
private final CodeArea codeArea;
private final Pane overlayPane = new Pane();
private final Map<LspInlineHintDTO, Label> labelsByHint = new LinkedHashMap<>();
private final Subscription viewportSubscription;
private List<LspInlineHintDTO> inlineHints = List.of();
EditorInlineHintOverlay(final CodeArea codeArea) {
this.codeArea = Objects.requireNonNull(codeArea, "codeArea");
overlayPane.setMouseTransparent(true);
overlayPane.setPickOnBounds(false);
overlayPane.getStyleClass().add("editor-inline-hint-layer");
codeArea.estimatedScrollXProperty().addListener((ignored, previous, current) -> refresh());
codeArea.estimatedScrollYProperty().addListener((ignored, previous, current) -> refresh());
codeArea.layoutBoundsProperty().addListener((ignored, previous, current) -> refresh());
codeArea.textProperty().addListener((ignored, previous, current) -> refresh());
overlayPane.layoutBoundsProperty().addListener((ignored, previous, current) -> refresh());
overlayPane.sceneProperty().addListener((ignored, previous, current) -> scheduleRefresh());
viewportSubscription = codeArea.viewportDirtyEvents().subscribe(ignored -> refresh());
}
Node node() {
return overlayPane;
}
void setInlineHints(final List<LspInlineHintDTO> inlineHints) {
this.inlineHints = List.copyOf(Objects.requireNonNull(inlineHints, "inlineHints"));
rebuildLabels();
scheduleRefresh();
}
void clear() {
setInlineHints(List.of());
}
void refreshLater() {
scheduleRefresh();
}
void dispose() {
viewportSubscription.unsubscribe();
}
private void rebuildLabels() {
labelsByHint.clear();
overlayPane.getChildren().clear();
for (final var inlineHint : inlineHints) {
final var label = new Label(EditorInlineHintLayout.displayText(inlineHint));
label.setManaged(false);
label.setMouseTransparent(true);
label.setPickOnBounds(false);
label.getStyleClass().add("editor-inline-hint");
label.getStyleClass().add(EditorInlineHintLayout.categoryStyleClass(inlineHint));
labelsByHint.put(inlineHint, label);
overlayPane.getChildren().add(label);
}
}
private void scheduleRefresh() {
Platform.runLater(this::refresh);
}
private void refresh() {
if (overlayPane.getScene() == null || inlineHints.isEmpty()) {
overlayPane.getChildren().forEach(node -> node.setVisible(false));
return;
}
final var placements = EditorInlineHintLayout.layout(inlineHints, this::resolveAnchorBounds);
final var placedHints = new java.util.HashSet<LspInlineHintDTO>();
for (final var placement : placements) {
final var label = labelsByHint.get(placement.inlineHint());
if (label == null) {
continue;
}
label.applyCss();
label.autosize();
final Point2D localPoint = overlayPane.screenToLocal(placement.screenX(), placement.screenY());
if (!Double.isFinite(localPoint.getX()) || !Double.isFinite(localPoint.getY())) {
label.setVisible(false);
continue;
}
final double layoutY = localPoint.getY() + Math.max(0.0d, (placement.anchorHeight() - label.getHeight()) / 2.0d);
label.relocate(localPoint.getX(), layoutY);
label.setVisible(true);
placedHints.add(placement.inlineHint());
}
labelsByHint.forEach((inlineHint, label) -> {
if (!placedHints.contains(inlineHint)) {
label.setVisible(false);
}
});
}
private Optional<EditorInlineHintLayout.AnchorBounds> resolveAnchorBounds(final LspInlineHintDTO inlineHint) {
if (codeArea.getLength() == 0) {
return Optional.empty();
}
final int anchorOffset = Math.max(0, Math.min(codeArea.getLength(), inlineHint.anchor().endOffset()));
final Optional<Bounds> characterBounds = codeArea.getCharacterBoundsOnScreen(anchorOffset, anchorOffset);
return characterBounds.map(bounds -> new EditorInlineHintLayout.AnchorBounds(
bounds.getMinX(),
bounds.getMinY(),
bounds.getMaxX(),
bounds.getHeight()));
}
}

View File

@ -1,159 +0,0 @@
package p.studio.workspaces.editor;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;
import org.fxmisc.richtext.model.StyleSpan;
import p.studio.lsp.dtos.LspInlineHintDTO;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
final class EditorInlineHintProjection {
private final String displayText;
private final StyleSpans<Collection<String>> displayStyles;
private final List<ProtectedRange> protectedRanges;
private EditorInlineHintProjection(
final String displayText,
final StyleSpans<Collection<String>> displayStyles,
final List<ProtectedRange> protectedRanges) {
this.displayText = Objects.requireNonNull(displayText, "displayText");
this.displayStyles = Objects.requireNonNull(displayStyles, "displayStyles");
this.protectedRanges = new ArrayList<>(Objects.requireNonNull(protectedRanges, "protectedRanges"));
}
static EditorInlineHintProjection create(
final String sourceText,
final StyleSpans<Collection<String>> sourceStyles,
final List<LspInlineHintDTO> inlineHints) {
Objects.requireNonNull(sourceText, "sourceText");
Objects.requireNonNull(sourceStyles, "sourceStyles");
Objects.requireNonNull(inlineHints, "inlineHints");
if (inlineHints.isEmpty()) {
return new EditorInlineHintProjection(sourceText, sourceStyles, List.of());
}
final var sortedHints = inlineHints.stream()
.sorted(Comparator.comparingInt(hint -> hint.anchor().endOffset()))
.toList();
final var displayText = new StringBuilder(sourceText.length() + (sortedHints.size() * 8));
final var displayStyles = new StyleSpansBuilder<Collection<String>>();
final var protectedRanges = new ArrayList<ProtectedRange>();
int sourceOffset = 0;
int displayOffset = 0;
for (final var inlineHint : sortedHints) {
final int anchorOffset = Math.max(0, Math.min(sourceText.length(), inlineHint.anchor().endOffset()));
if (anchorOffset > sourceOffset) {
displayText.append(sourceText, sourceOffset, anchorOffset);
appendSourceStyles(displayStyles, sourceStyles.subView(sourceOffset, anchorOffset));
displayOffset += anchorOffset - sourceOffset;
sourceOffset = anchorOffset;
}
final String hintText = " " + EditorInlineHintLayout.displayText(inlineHint);
displayText.append(hintText);
displayStyles.add(hintClasses(inlineHint), hintText.length());
protectedRanges.add(new ProtectedRange(displayOffset, displayOffset + hintText.length()));
displayOffset += hintText.length();
}
if (sourceOffset < sourceText.length()) {
displayText.append(sourceText, sourceOffset, sourceText.length());
appendSourceStyles(displayStyles, sourceStyles.subView(sourceOffset, sourceText.length()));
}
return new EditorInlineHintProjection(displayText.toString(), displayStyles.create(), protectedRanges);
}
String displayText() {
return displayText;
}
StyleSpans<Collection<String>> displayStyles() {
return displayStyles;
}
String stripDecorations(final String currentDisplayText) {
Objects.requireNonNull(currentDisplayText, "currentDisplayText");
if (protectedRanges.isEmpty()) {
return currentDisplayText;
}
final var sourceText = new StringBuilder(currentDisplayText);
for (int index = protectedRanges.size() - 1; index >= 0; index--) {
final var range = protectedRanges.get(index);
final int start = Math.max(0, Math.min(sourceText.length(), range.start()));
final int end = Math.max(start, Math.min(sourceText.length(), range.end()));
sourceText.delete(start, end);
}
return sourceText.toString();
}
void applyDisplayChange(final int position, final int removedLength, final int insertedLength) {
final int delta = insertedLength - removedLength;
for (int index = 0; index < protectedRanges.size(); index++) {
final var range = protectedRanges.get(index);
if (range.end() <= position) {
continue;
}
if (range.start() >= position + removedLength) {
protectedRanges.set(index, range.shift(delta));
continue;
}
protectedRanges.set(index, range);
}
}
boolean containsOffset(final int offset) {
return protectedRanges.stream().anyMatch(range -> range.contains(offset));
}
boolean touchesRange(final int start, final int end) {
if (start == end) {
return containsOffset(start);
}
return protectedRanges.stream().anyMatch(range -> range.intersects(start, end));
}
int clampCaret(final int offset, final boolean preferRightBoundary) {
for (final var range : protectedRanges) {
if (!range.contains(offset)) {
continue;
}
return preferRightBoundary ? range.end() : range.start();
}
return offset;
}
private static void appendSourceStyles(
final StyleSpansBuilder<Collection<String>> builder,
final StyleSpans<Collection<String>> styles) {
for (final StyleSpan<Collection<String>> span : styles) {
builder.add(span.getStyle(), span.getLength());
}
}
private static Collection<String> hintClasses(final LspInlineHintDTO inlineHint) {
final var classes = new LinkedHashSet<String>();
classes.add("editor-inline-hint");
classes.add(EditorInlineHintLayout.categoryStyleClass(inlineHint));
return List.copyOf(classes);
}
record ProtectedRange(int start, int end) {
boolean contains(final int offset) {
return offset >= start && offset < end;
}
boolean intersects(final int rangeStart, final int rangeEnd) {
return rangeStart < end && rangeEnd > start;
}
ProtectedRange shift(final int delta) {
return new ProtectedRange(start + delta, end + delta);
}
}
}

View File

@ -1,37 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.vfs.messages.VfsDocumentAccessMode;
import java.nio.file.Path;
import java.util.Objects;
public record EditorOpenFileBuffer(
Path path,
String tabLabel,
String typeId,
String content,
String lineSeparator,
boolean frontendDocument,
VfsDocumentAccessMode accessMode,
boolean dirty) {
public EditorOpenFileBuffer {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
tabLabel = Objects.requireNonNull(tabLabel, "tabLabel");
typeId = Objects.requireNonNull(typeId, "typeId");
content = Objects.requireNonNull(content, "content");
lineSeparator = Objects.requireNonNull(lineSeparator, "lineSeparator");
Objects.requireNonNull(accessMode, "accessMode");
}
public boolean editable() {
return accessMode == VfsDocumentAccessMode.EDITABLE;
}
public boolean readOnly() {
return !editable();
}
public boolean saveEnabled() {
return editable() && dirty;
}
}

View File

@ -1,112 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.projectstate.ProjectLocalStudioState;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public final class EditorOpenFileSession {
private final List<EditorOpenFileBuffer> openFiles = new ArrayList<>();
private Path activePath;
public void open(final EditorOpenFileBuffer fileBuffer) {
final var buffer = Objects.requireNonNull(fileBuffer, "fileBuffer");
final var existingIndex = indexOf(buffer.path());
if (existingIndex < 0) {
openFiles.add(buffer);
} else {
openFiles.set(existingIndex, buffer);
}
activePath = buffer.path();
}
public void activate(final Path path) {
final var normalizedPath = normalize(path);
if (find(normalizedPath).isPresent()) {
activePath = normalizedPath;
}
}
public void close(final Path path) {
final int index = indexOf(path);
if (index < 0) {
return;
}
final Path normalizedPath = normalize(path);
openFiles.remove(index);
if (openFiles.isEmpty()) {
activePath = null;
return;
}
if (!normalizedPath.equals(activePath)) {
return;
}
final int nextIndex = Math.min(index, openFiles.size() - 1);
activePath = openFiles.get(nextIndex).path();
}
public List<EditorOpenFileBuffer> openFiles() {
return List.copyOf(openFiles);
}
public Optional<EditorOpenFileBuffer> activeFile() {
if (activePath == null) {
return Optional.empty();
}
return find(activePath);
}
public Optional<EditorOpenFileBuffer> file(final Path path) {
return find(normalize(path));
}
public boolean hasDirtyEditableFiles() {
return openFiles.stream().anyMatch(EditorOpenFileBuffer::saveEnabled);
}
public boolean hasSaveableActiveFile() {
return activeFile().map(EditorOpenFileBuffer::saveEnabled).orElse(false);
}
public boolean isEmpty() {
return openFiles.isEmpty();
}
public void clear() {
openFiles.clear();
activePath = null;
}
public ProjectLocalStudioState.EditorRestorationState restorationState() {
return new ProjectLocalStudioState.EditorRestorationState(
openFiles.stream()
.map(buffer -> buffer.path().toString())
.toList(),
activePath == null ? null : activePath.toString());
}
private Optional<EditorOpenFileBuffer> find(final Path path) {
final int index = indexOf(path);
if (index < 0) {
return Optional.empty();
}
return Optional.of(openFiles.get(index));
}
private int indexOf(final Path path) {
final var normalizedPath = normalize(path);
for (int index = 0; index < openFiles.size(); index++) {
if (openFiles.get(index).path().equals(normalizedPath)) {
return index;
}
}
return -1;
}
private Path normalize(final Path path) {
return Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
}
}

View File

@ -1,116 +0,0 @@
package p.studio.workspaces.editor;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import p.studio.lsp.dtos.LspSymbolDTO;
import p.studio.lsp.messages.LspSymbolKind;
import p.studio.Container;
import p.studio.controls.WorkspaceDockPane;
import p.studio.utilities.i18n.I18n;
import java.nio.file.Path;
import java.util.List;
public final class EditorOutlinePanel extends WorkspaceDockPane {
private static final double COLLAPSED_HEIGHT = 34.0;
private static final double MINIMUM_EXPANDED_HEIGHT = 120.0;
private static final double DEFAULT_HEIGHT = 180.0;
private final Label summary = new Label();
private final VBox symbolsBox = new VBox(6);
public EditorOutlinePanel() {
super(
Container.i18n().bind(I18n.CODE_EDITOR_OUTLINE_TITLE),
COLLAPSED_HEIGHT,
MINIMUM_EXPANDED_HEIGHT,
DEFAULT_HEIGHT,
true,
"editor-workspace-outline-panel");
summary.getStyleClass().addAll("editor-workspace-placeholder", "editor-workspace-outline-summary");
summary.setWrapText(true);
final var symbolsTitle = sectionTitle(I18n.CODE_EDITOR_OUTLINE_SYMBOLS);
symbolsBox.getStyleClass().add("editor-workspace-outline-list");
final var content = new VBox(10,
summary,
symbolsTitle,
symbolsBox);
content.getStyleClass().add("editor-workspace-panel-content");
content.setPadding(new Insets(10, 16, 14, 16));
final var scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
scrollPane.getStyleClass().add("editor-workspace-outline-scroll");
setDockContent(scrollPane);
showPlaceholder();
}
public void showPlaceholder() {
summary.textProperty().unbind();
summary.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_OUTLINE_PLACEHOLDER));
symbolsBox.getChildren().setAll(placeholderLabel(I18n.CODE_EDITOR_OUTLINE_EMPTY_SYMBOLS));
}
public void showSemanticReadResult(
final Path documentPath,
final List<LspSymbolDTO> symbols) {
summary.textProperty().unbind();
summary.setText(documentPath.getFileName() + " • semantic read-only");
rebuildSymbols(symbols);
}
private void rebuildSymbols(final List<LspSymbolDTO> symbols) {
symbolsBox.getChildren().clear();
int visible = 0;
for (final LspSymbolDTO symbol : symbols) {
visible += appendSymbol(symbol, 0);
}
if (visible == 0) {
symbolsBox.getChildren().add(placeholderLabel(I18n.CODE_EDITOR_OUTLINE_EMPTY_SYMBOLS));
}
}
private int appendSymbol(final LspSymbolDTO symbol, final int depth) {
int visible = 0;
int childDepth = depth;
if (showInOutline(symbol.kind())) {
final var label = new Label(symbol.name() + "" + symbol.kind().name().toLowerCase());
label.setWrapText(true);
label.setPadding(new Insets(0, 0, 0, depth * 12));
label.getStyleClass().add("editor-workspace-outline-item");
symbolsBox.getChildren().add(label);
visible++;
childDepth++;
}
for (final LspSymbolDTO child : symbol.children()) {
visible += appendSymbol(child, childDepth);
}
return visible;
}
private boolean showInOutline(final LspSymbolKind kind) {
return switch (kind) {
case IF, SWITCH, FOR, WHILE, HANDLE -> false;
default -> true;
};
}
private Label sectionTitle(final I18n key) {
final var label = new Label();
label.textProperty().bind(Container.i18n().bind(key));
label.getStyleClass().add("editor-workspace-outline-section-title");
return label;
}
private Label placeholderLabel(final I18n key) {
final var label = new Label();
label.textProperty().bind(Container.i18n().bind(key));
label.setWrapText(true);
label.getStyleClass().addAll("editor-workspace-placeholder", "editor-workspace-outline-item");
return label;
}
}

View File

@ -1,337 +0,0 @@
package p.studio.workspaces.editor;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import p.studio.Container;
import p.studio.utilities.i18n.I18n;
import p.studio.vfs.messages.VfsProjectNode;
import p.studio.vfs.messages.VfsProjectSnapshot;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
public final class EditorProjectNavigatorPanel extends BorderPane {
private final Button revealActiveButton = new Button();
private final Button refreshButton = new Button();
private final TreeView<VfsProjectNode> treeView = new TreeView<>();
private final Label emptyState = emptyLabel();
private final StackPane content = new StackPane();
private Runnable revealActiveFileAction = () -> { };
private Runnable refreshAction = () -> { };
private Consumer<VfsProjectNode> fileSelectionAction = node -> { };
private Set<Path> preferredExpandedPaths = Set.of();
public EditorProjectNavigatorPanel() {
getStyleClass().addAll("editor-workspace-panel", "editor-workspace-navigator-panel");
setPadding(new Insets(14, 16, 14, 16));
setTop(buildHeader());
configureTreeView();
content.getStyleClass().add("editor-workspace-panel-content");
content.getChildren().addAll(treeView, emptyState);
setCenter(content);
showEmptyState(true);
}
private HBox buildHeader() {
final var title = new Label();
title.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_TITLE));
title.getStyleClass().add("editor-workspace-panel-title");
revealActiveButton.setFocusTraversable(false);
revealActiveButton.setDisable(true);
revealActiveButton.getStyleClass().addAll(
"studio-button",
"studio-button-icon",
"editor-workspace-header-icon-button");
revealActiveButton.setGraphic(EditorWorkspaceIcons.target());
revealActiveButton.setTooltip(new Tooltip(Container.i18n().text(I18n.CODE_EDITOR_NAVIGATOR_REVEAL_ACTIVE)));
revealActiveButton.setOnAction(event -> revealActiveFileAction.run());
refreshButton.setFocusTraversable(false);
refreshButton.getStyleClass().addAll(
"studio-button",
"studio-button-icon",
"editor-workspace-header-icon-button");
refreshButton.setGraphic(EditorWorkspaceIcons.refresh());
refreshButton.setTooltip(new Tooltip(Container.i18n().text(I18n.CODE_EDITOR_NAVIGATOR_REFRESH)));
refreshButton.setOnAction(event -> refreshAction.run());
final var spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
final var header = new HBox(8, title, spacer, revealActiveButton, refreshButton);
header.getStyleClass().add("editor-workspace-panel-header");
return header;
}
private void configureTreeView() {
treeView.getStyleClass().add("editor-workspace-tree");
treeView.setShowRoot(true);
treeView.setFocusTraversable(false);
treeView.setCellFactory(ignored -> new TreeCell<>() {
@Override
protected void updateItem(final VfsProjectNode item, final boolean empty) {
super.updateItem(item, empty);
getStyleClass().remove("editor-workspace-tree-cell-tagged");
getStyleClass().remove("editor-workspace-tree-cell-root");
if (empty || item == null) {
setText(null);
setGraphic(null);
return;
}
setText(labelFor(item, getTreeItem()));
setGraphic(iconFor(item, getTreeItem()));
if (isRoot(getTreeItem())) {
getStyleClass().add("editor-workspace-tree-cell-root");
}
if (item.taggedSourceRoot() || isInsideTaggedSourceRoot(getTreeItem())) {
getStyleClass().add("editor-workspace-tree-cell-tagged");
}
}
});
treeView.setOnMouseClicked(event -> {
if (event.getClickCount() != 2) {
return;
}
final TreeItem<VfsProjectNode> current = treeView.getSelectionModel().getSelectedItem();
if (current == null || current.getValue() == null || current.getValue().directory()) {
return;
}
fileSelectionAction.accept(current.getValue());
});
}
public void setRefreshAction(final Runnable refreshAction) {
this.refreshAction = Objects.requireNonNull(refreshAction, "refreshAction");
}
public void setRevealActiveFileAction(final Runnable revealActiveFileAction) {
this.revealActiveFileAction = Objects.requireNonNull(revealActiveFileAction, "revealActiveFileAction");
}
public void setRevealActiveFileAvailable(final boolean available) {
revealActiveButton.setDisable(!available);
}
public void setFileSelectionAction(final Consumer<VfsProjectNode> fileSelectionAction) {
this.fileSelectionAction = Objects.requireNonNull(fileSelectionAction, "fileSelectionAction");
}
public void setSnapshot(final VfsProjectSnapshot snapshot) {
final var currentSelection = selectedPath();
final var expandedPaths = mergeExpandedPaths();
final var rootItem = buildTreeItem(Objects.requireNonNull(snapshot, "snapshot").rootNode(), expandedPaths, true);
treeView.setRoot(rootItem);
restoreSelection(rootItem, currentSelection);
showEmptyState(false);
}
public List<String> expandedPathStrings() {
return captureExpandedPaths(treeView.getRoot()).stream()
.map(Path::toString)
.toList();
}
public void restoreExpandedPaths(final List<String> expandedPaths) {
final LinkedHashSet<Path> restored = new LinkedHashSet<>();
if (expandedPaths != null) {
for (final String expandedPath : expandedPaths) {
if (expandedPath == null || expandedPath.isBlank()) {
continue;
}
try {
restored.add(Path.of(expandedPath).toAbsolutePath().normalize());
} catch (RuntimeException ignored) {
// Ignore malformed persisted paths and continue with the remaining ones.
}
}
}
preferredExpandedPaths = Set.copyOf(restored);
final TreeItem<VfsProjectNode> rootItem = treeView.getRoot();
if (rootItem != null) {
final var currentSelection = selectedPath();
treeView.setRoot(buildTreeItem(rootItem.getValue(), preferredExpandedPaths, true));
restoreSelection(treeView.getRoot(), currentSelection);
}
}
public void revealPath(final Path path) {
final var normalizedPath = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
final var rootItem = treeView.getRoot();
if (rootItem == null) {
return;
}
findTreeItem(rootItem, normalizedPath).ifPresent(item -> {
expandAncestors(item);
treeView.getSelectionModel().select(item);
treeView.scrollTo(Math.max(treeView.getRow(item) - 2, 0));
});
}
private TreeItem<VfsProjectNode> buildTreeItem(
final VfsProjectNode node,
final Set<Path> expandedPaths,
final boolean isRoot) {
final TreeItem<VfsProjectNode> item = node.directory()
? new DirectoryTreeItem(node)
: new TreeItem<>(node);
item.setExpanded(isRoot || expandedPaths.contains(node.path()));
node.children().stream()
.map(child -> buildTreeItem(child, expandedPaths, false))
.forEach(item.getChildren()::add);
return item;
}
private Set<Path> captureExpandedPaths(final TreeItem<VfsProjectNode> item) {
final var expandedPaths = new HashSet<Path>();
if (item == null) {
return expandedPaths;
}
collectExpandedPaths(item, expandedPaths);
return expandedPaths;
}
private void collectExpandedPaths(final TreeItem<VfsProjectNode> item, final Set<Path> expandedPaths) {
final var value = item.getValue();
if (value != null && item.isExpanded()) {
expandedPaths.add(value.path());
}
item.getChildren().forEach(child -> collectExpandedPaths(child, expandedPaths));
}
private Set<Path> mergeExpandedPaths() {
final LinkedHashSet<Path> merged = new LinkedHashSet<>(preferredExpandedPaths);
merged.addAll(captureExpandedPaths(treeView.getRoot()));
return Set.copyOf(merged);
}
private Optional<Path> selectedPath() {
return Optional.ofNullable(treeView.getSelectionModel().getSelectedItem())
.map(TreeItem::getValue)
.map(VfsProjectNode::path);
}
private void restoreSelection(final TreeItem<VfsProjectNode> rootItem, final Optional<Path> selectedPath) {
treeView.getSelectionModel().clearSelection();
if (selectedPath.isEmpty()) {
return;
}
findTreeItem(rootItem, selectedPath.orElseThrow())
.ifPresent(item -> treeView.getSelectionModel().select(item));
}
private Optional<TreeItem<VfsProjectNode>> findTreeItem(final TreeItem<VfsProjectNode> item, final Path path) {
if (item == null || item.getValue() == null) {
return Optional.empty();
}
if (item.getValue().path().equals(path)) {
return Optional.of(item);
}
for (final var child : item.getChildren()) {
final var match = findTreeItem(child, path);
if (match.isPresent()) {
return match;
}
}
return Optional.empty();
}
private void showEmptyState(final boolean empty) {
treeView.setVisible(!empty);
treeView.setManaged(!empty);
emptyState.setVisible(empty);
emptyState.setManaged(empty);
}
private void expandAncestors(final TreeItem<VfsProjectNode> item) {
TreeItem<VfsProjectNode> current = item;
while (current != null) {
current.setExpanded(true);
current = current.getParent();
}
}
private Node iconFor(final VfsProjectNode node, final TreeItem<VfsProjectNode> treeItem) {
if (node.directory()) {
if (isBuildTone(node)) {
return EditorWorkspaceIcons.folderBuild();
}
if (node.taggedSourceRoot() || isInsideTaggedSourceRoot(treeItem)) {
return EditorWorkspaceIcons.folderSource();
}
return EditorWorkspaceIcons.folderDefault();
}
if ("prometeu.json".equals(node.displayName())) {
return EditorWorkspaceIcons.cog();
}
return EditorWorkspaceIcons.file();
}
private String labelFor(final VfsProjectNode node, final TreeItem<VfsProjectNode> treeItem) {
if (!isRoot(treeItem)) {
return node.displayName();
}
final var directoryName = Optional.ofNullable(node.path().getFileName())
.map(Path::toString)
.filter(name -> !name.isBlank())
.orElse(node.displayName());
return directoryName + " (" + node.displayName() + ")";
}
private boolean isInsideTaggedSourceRoot(final TreeItem<VfsProjectNode> item) {
var current = item == null ? null : item.getParent();
while (current != null && current.getValue() != null) {
if (current.getValue().taggedSourceRoot()) {
return true;
}
current = current.getParent();
}
return false;
}
private boolean isRoot(final TreeItem<VfsProjectNode> item) {
return item != null && item.getParent() == null;
}
private boolean isBuildTone(final VfsProjectNode node) {
return node.directory()
&& ("build".equals(node.displayName()) || "cartridge".equals(node.displayName()));
}
private Label emptyLabel() {
final var placeholder = new Label();
placeholder.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_PLACEHOLDER));
placeholder.getStyleClass().add("editor-workspace-placeholder");
placeholder.setWrapText(true);
return placeholder;
}
private static final class DirectoryTreeItem extends TreeItem<VfsProjectNode> {
private DirectoryTreeItem(final VfsProjectNode node) {
super(node);
}
@Override
public boolean isLeaf() {
return false;
}
}
}

View File

@ -1,218 +0,0 @@
package p.studio.workspaces.editor;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import p.studio.Container;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import java.nio.file.Path;
import java.util.Optional;
public final class EditorStatusBar extends HBox {
private final HBox breadcrumb = new HBox(6);
private final Label position = new Label();
private final Label lineSeparator = new Label();
private final Label indentation = new Label();
private final Label language = new Label();
private final StackPane accessMode = new StackPane();
public EditorStatusBar() {
setAlignment(Pos.CENTER_LEFT);
setSpacing(8);
getStyleClass().add("editor-workspace-status-bar");
breadcrumb.getStyleClass().add("editor-workspace-status-breadcrumb");
breadcrumb.setAlignment(Pos.CENTER_LEFT);
final var spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
styleChip(position);
position.getStyleClass().add("editor-workspace-status-chip-position");
styleChip(lineSeparator);
lineSeparator.getStyleClass().add("editor-workspace-status-chip-line-separator");
styleChip(indentation);
indentation.getStyleClass().add("editor-workspace-status-chip-indentation");
styleChip(language);
language.getStyleClass().add("editor-workspace-status-chip-language");
accessMode.getStyleClass().addAll("editor-workspace-status-chip", "editor-workspace-status-chip-access-mode");
getChildren().addAll(
breadcrumb,
spacer,
position,
lineSeparator,
indentation,
language,
accessMode
);
}
public void showFile(
final ProjectReference projectReference,
final EditorOpenFileBuffer fileBuffer,
final EditorDocumentPresentation presentation,
final String indentationPolicyLabel) {
showBreadcrumb(projectReference, fileBuffer.path());
showMetadata(true);
showPosition(fileBuffer.editable(), 1, 1);
showDocumentFormatting(fileBuffer.lineSeparator(), indentationPolicyLabel);
setText(language, fileBuffer.typeId());
EditorDocumentPresentationStyles.applyToStatusChip(language, presentation);
showAccessMode(fileBuffer.readOnly());
}
public void showPlaceholder(final EditorDocumentPresentation presentation) {
breadcrumb.getChildren().clear();
showMetadata(false);
showPosition(false, 1, 1);
bindDefault(lineSeparator, I18n.CODE_EDITOR_STATUS_LINE_SEPARATOR);
bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION);
bindDefault(language, I18n.CODE_EDITOR_STATUS_LANGUAGE);
EditorDocumentPresentationStyles.applyToStatusChip(language, presentation);
accessMode.getChildren().clear();
accessMode.getStyleClass().removeAll("editor-workspace-status-chip-editable", "editor-workspace-status-chip-read-only");
}
public void showCaretPosition(final int line, final int column) {
if (!position.isManaged()) {
return;
}
setText(position, line + ":" + column);
}
public void showDocumentFormatting(final String separator, final String indentationPolicyLabel) {
setText(lineSeparator, separator);
setText(indentation, indentationPolicyLabel);
}
private void bindDefault(final Label label, final I18n key) {
bindText(label, key);
}
private void bindText(final Label label, final I18n key) {
label.textProperty().unbind();
label.textProperty().bind(Container.i18n().bind(key));
}
private void showBreadcrumb(final ProjectReference projectReference, final Path filePath) {
breadcrumb.getChildren().clear();
breadcrumb.getChildren().add(segment(
rootLabel(projectReference),
EditorWorkspaceIcons.folderDefault(),
true));
final var relativePath = projectReference.rootPath()
.toAbsolutePath()
.normalize()
.relativize(filePath.toAbsolutePath().normalize());
boolean insideSourceTree = false;
Path accumulated = projectReference.rootPath().toAbsolutePath().normalize();
for (int index = 0; index < relativePath.getNameCount(); index++) {
final var part = relativePath.getName(index).toString();
accumulated = accumulated.resolve(part);
final boolean isLast = index == relativePath.getNameCount() - 1;
if ("src".equals(part)) {
insideSourceTree = true;
}
breadcrumb.getChildren().add(separator());
breadcrumb.getChildren().add(segment(
part,
iconForSegment(part, isLast, insideSourceTree),
false));
}
}
private String rootLabel(final ProjectReference projectReference) {
return Optional.ofNullable(projectReference.rootPath().getFileName())
.map(Path::toString)
.filter(name -> !name.isBlank())
.orElse(projectReference.name());
}
private HBox segment(final String text, final Node icon, final boolean root) {
final var label = new Label(text);
label.getStyleClass().add("editor-workspace-status-breadcrumb-text");
final var box = new HBox(6, icon, label);
box.setAlignment(Pos.CENTER_LEFT);
box.getStyleClass().add("editor-workspace-status-breadcrumb-segment");
if (root) {
box.getStyleClass().add("editor-workspace-status-breadcrumb-segment-root");
}
return box;
}
private Label separator() {
final var label = new Label(">");
label.getStyleClass().add("editor-workspace-status-breadcrumb-separator");
return label;
}
private Node iconForSegment(final String name, final boolean isFile, final boolean insideSourceTree) {
if (isFile) {
if ("prometeu.json".equals(name)) {
return EditorWorkspaceIcons.cog();
}
return EditorWorkspaceIcons.file();
}
if ("build".equals(name) || "cartridge".equals(name)) {
return EditorWorkspaceIcons.folderBuild();
}
if ("src".equals(name) || insideSourceTree) {
return EditorWorkspaceIcons.folderSource();
}
return EditorWorkspaceIcons.folderDefault();
}
private void setText(final Label label, final String text) {
label.textProperty().unbind();
label.setText(text);
}
private void showMetadata(final boolean visible) {
setVisibleManaged(lineSeparator, visible);
setVisibleManaged(indentation, visible);
setVisibleManaged(language, visible);
setVisibleManaged(accessMode, visible);
}
private void showPosition(final boolean visible, final int line, final int column) {
setVisibleManaged(position, visible);
if (!visible) {
bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION);
return;
}
setText(position, line + ":" + column);
}
private void showAccessMode(final boolean readOnlyMode) {
accessMode.getChildren().setAll(readOnlyMode
? EditorWorkspaceIcons.lockClosed()
: EditorWorkspaceIcons.lockOpen());
accessMode.getStyleClass().removeAll("editor-workspace-status-chip-editable", "editor-workspace-status-chip-read-only");
accessMode.getStyleClass().add(readOnlyMode
? "editor-workspace-status-chip-read-only"
: "editor-workspace-status-chip-editable");
}
private void setVisibleManaged(final Node node, final boolean visible) {
node.setVisible(visible);
node.setManaged(visible);
}
private void styleChip(final Label label) {
if (!label.getStyleClass().contains("editor-workspace-status-chip")) {
label.getStyleClass().add("editor-workspace-status-chip");
}
}
}

View File

@ -1,252 +0,0 @@
package p.studio.workspaces.editor;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.CustomMenuItem;
import javafx.scene.control.Label;
import javafx.scene.control.MenuButton;
import javafx.scene.control.MenuItem;
import javafx.scene.control.OverrunStyle;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.SVGPath;
import p.studio.Container;
import p.studio.utilities.i18n.I18n;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
public final class EditorTabStrip extends HBox {
private static final PseudoClass PRESSED_PSEUDO_CLASS = PseudoClass.getPseudoClass("pressed");
private static final double TAB_WIDTH_HINT = 176.0;
private static final double TAB_HEIGHT_HINT = 34.0;
private static final double STRIP_HEIGHT_HINT = 50.0;
private static final double OVERFLOW_WIDTH_HINT = 128.0;
private static final double OVERFLOW_MENU_WIDTH_HINT = 180.0;
private final MenuButton overflowButton = new MenuButton();
private final Region spacer = new Region();
private final List<EditorOpenFileBuffer> openFiles = new ArrayList<>();
private Consumer<Path> tabSelectionAction = path -> { };
private Consumer<Path> tabCloseAction = path -> { };
private Path activePath;
public EditorTabStrip() {
setAlignment(Pos.CENTER_LEFT);
setSpacing(10);
getStyleClass().add("editor-workspace-tab-strip");
setMinHeight(STRIP_HEIGHT_HINT);
setPrefHeight(STRIP_HEIGHT_HINT);
setMaxHeight(STRIP_HEIGHT_HINT);
HBox.setHgrow(spacer, Priority.ALWAYS);
overflowButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_OVERFLOW));
overflowButton.setFocusTraversable(false);
overflowButton.getStyleClass().addAll(
"studio-button",
"studio-button-secondary",
"editor-workspace-tab-overflow");
applyOverflowMetrics();
widthProperty().addListener((ignored, previous, current) -> rebuild());
rebuild();
}
public void setTabSelectionAction(final Consumer<Path> tabSelectionAction) {
this.tabSelectionAction = Objects.requireNonNull(tabSelectionAction, "tabSelectionAction");
}
public void setTabCloseAction(final Consumer<Path> tabCloseAction) {
this.tabCloseAction = Objects.requireNonNull(tabCloseAction, "tabCloseAction");
}
public void showOpenFiles(final List<EditorOpenFileBuffer> files, final Path activePath) {
this.openFiles.clear();
this.openFiles.addAll(Objects.requireNonNull(files, "files"));
this.activePath = activePath == null ? null : activePath.toAbsolutePath().normalize();
rebuild();
}
private void rebuild() {
getChildren().clear();
if (openFiles.isEmpty()) {
overflowButton.getItems().clear();
getChildren().add(spacer);
overflowButton.setVisible(false);
overflowButton.setManaged(false);
return;
}
final int visibleCount = visibleCount();
final int activeIndex = activeIndex();
final int startIndex = Math.max(0, Math.min(activeIndex - visibleCount + 1, openFiles.size() - visibleCount));
final int endIndex = Math.min(openFiles.size(), startIndex + visibleCount);
for (int index = startIndex; index < endIndex; index++) {
final var fileBuffer = openFiles.get(index);
final var tabButton = new Button(fileBuffer.tabLabel());
final var tabLabel = new Label(fileBuffer.tabLabel());
final var dirtyIndicator = new StackPane();
final var closeChip = new StackPane();
final var closeIcon = new SVGPath();
final var tabContent = new HBox();
tabButton.setFocusTraversable(false);
tabButton.getStyleClass().addAll(
"studio-button",
"studio-button-secondary",
"editor-workspace-tab-button");
tabButton.getStyleClass().add(
fileBuffer.readOnly()
? "editor-workspace-tab-button-read-only"
: "editor-workspace-tab-button-editable");
tabLabel.getStyleClass().add("editor-workspace-tab-label");
tabLabel.setTextOverrun(OverrunStyle.ELLIPSIS);
dirtyIndicator.getStyleClass().add("editor-workspace-tab-dirty-indicator");
closeChip.getStyleClass().add("editor-workspace-tab-close-chip");
closeIcon.setContent("M 3 3 L 9 9 M 9 3 L 3 9");
closeIcon.getStyleClass().add("editor-workspace-tab-close-icon");
closeIcon.setMouseTransparent(true);
closeChip.getChildren().add(closeIcon);
tabContent.getStyleClass().add("editor-workspace-tab-content");
tabContent.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(tabLabel, Priority.ALWAYS);
dirtyIndicator.setManaged(fileBuffer.dirty());
dirtyIndicator.setVisible(fileBuffer.dirty());
tabContent.getChildren().addAll(tabLabel, dirtyIndicator, closeChip);
tabButton.setText(null);
tabButton.setGraphic(tabContent);
applyTabMetrics(tabButton, tabLabel, dirtyIndicator, closeChip);
if (fileBuffer.path().equals(activePath)) {
tabButton.getStyleClass().add("editor-workspace-tab-button-active");
}
tabButton.setOnAction(event -> tabSelectionAction.accept(fileBuffer.path()));
configureCloseChip(closeChip, fileBuffer.path());
getChildren().add(tabButton);
}
getChildren().add(spacer);
final boolean hasOverflow = openFiles.size() > visibleCount;
overflowButton.getItems().setAll(buildOverflowItems(startIndex, endIndex));
overflowButton.setVisible(hasOverflow);
overflowButton.setManaged(hasOverflow);
overflowButton.setDisable(!hasOverflow);
getChildren().add(overflowButton);
}
private List<MenuItem> buildOverflowItems(final int startIndex, final int endIndex) {
final var items = new ArrayList<MenuItem>();
for (int index = 0; index < openFiles.size(); index++) {
if (index >= startIndex && index < endIndex) {
continue;
}
final var fileBuffer = openFiles.get(index);
final var label = new Label(fileBuffer.tabLabel());
label.getStyleClass().add("editor-workspace-tab-overflow-item");
label.setMinWidth(OVERFLOW_MENU_WIDTH_HINT);
label.setPrefWidth(OVERFLOW_MENU_WIDTH_HINT);
label.setMaxWidth(OVERFLOW_MENU_WIDTH_HINT);
label.setMinHeight(TAB_HEIGHT_HINT);
label.setPrefHeight(TAB_HEIGHT_HINT);
label.setMaxHeight(TAB_HEIGHT_HINT);
label.setAlignment(Pos.CENTER_LEFT);
label.setTextOverrun(OverrunStyle.ELLIPSIS);
final var item = new CustomMenuItem(label, true);
item.getStyleClass().add("editor-workspace-tab-overflow-menu-item");
item.setOnAction(event -> tabSelectionAction.accept(fileBuffer.path()));
items.add(item);
}
return items;
}
private int activeIndex() {
if (activePath == null) {
return Math.max(0, openFiles.size() - 1);
}
for (int index = 0; index < openFiles.size(); index++) {
if (openFiles.get(index).path().equals(activePath)) {
return index;
}
}
return Math.max(0, openFiles.size() - 1);
}
private int visibleCount() {
final double availableWidth = getWidth() > 0 ? getWidth() : 760;
final double usableWidth = Math.max(availableWidth - OVERFLOW_WIDTH_HINT, TAB_WIDTH_HINT);
return Math.max(1, (int) Math.floor(usableWidth / TAB_WIDTH_HINT));
}
private void applyTabMetrics(
final Button button,
final Label tabLabel,
final StackPane dirtyIndicator,
final StackPane closeChip) {
button.setMinWidth(TAB_WIDTH_HINT);
button.setPrefWidth(TAB_WIDTH_HINT);
button.setMaxWidth(TAB_WIDTH_HINT);
button.setMinHeight(TAB_HEIGHT_HINT);
button.setPrefHeight(TAB_HEIGHT_HINT);
button.setMaxHeight(TAB_HEIGHT_HINT);
button.setMnemonicParsing(false);
tabLabel.setMinWidth(0);
tabLabel.setPrefWidth(TAB_WIDTH_HINT - 30);
tabLabel.setMaxWidth(Double.MAX_VALUE);
dirtyIndicator.setMinWidth(8);
dirtyIndicator.setPrefWidth(8);
dirtyIndicator.setMaxWidth(8);
dirtyIndicator.setMinHeight(8);
dirtyIndicator.setPrefHeight(8);
dirtyIndicator.setMaxHeight(8);
closeChip.setMinWidth(14);
closeChip.setPrefWidth(14);
closeChip.setMaxWidth(14);
closeChip.setMinHeight(14);
closeChip.setPrefHeight(14);
closeChip.setMaxHeight(14);
}
private void applyOverflowMetrics() {
overflowButton.setMinWidth(OVERFLOW_WIDTH_HINT);
overflowButton.setPrefWidth(OVERFLOW_WIDTH_HINT);
overflowButton.setMaxWidth(OVERFLOW_WIDTH_HINT);
overflowButton.setMinHeight(TAB_HEIGHT_HINT);
overflowButton.setPrefHeight(TAB_HEIGHT_HINT);
overflowButton.setMaxHeight(TAB_HEIGHT_HINT);
overflowButton.setMnemonicParsing(false);
}
private void configureCloseChip(final StackPane closeChip, final Path filePath) {
closeChip.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
event.consume();
if (event.getButton() == MouseButton.PRIMARY && closeChip.contains(event.getX(), event.getY())) {
closeChip.pseudoClassStateChanged(PRESSED_PSEUDO_CLASS, true);
}
});
closeChip.addEventFilter(MouseEvent.MOUSE_DRAGGED, event -> {
event.consume();
closeChip.pseudoClassStateChanged(
PRESSED_PSEUDO_CLASS,
event.isPrimaryButtonDown() && closeChip.contains(event.getX(), event.getY()));
});
closeChip.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> {
event.consume();
final boolean shouldClose = event.getButton() == MouseButton.PRIMARY
&& closeChip.getPseudoClassStates().contains(PRESSED_PSEUDO_CLASS)
&& closeChip.contains(event.getX(), event.getY());
closeChip.pseudoClassStateChanged(PRESSED_PSEUDO_CLASS, false);
if (shouldClose) {
tabCloseAction.accept(filePath);
}
});
closeChip.addEventFilter(MouseEvent.MOUSE_EXITED, event ->
closeChip.pseudoClassStateChanged(PRESSED_PSEUDO_CLASS, false));
}
}

View File

@ -1,103 +0,0 @@
package p.studio.workspaces.editor;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import java.util.List;
import java.util.Objects;
public final class EditorWarningStrip extends HBox {
private final Label message = new Label();
private final Label counter = new Label();
private final Button previousButton = new Button("");
private final Button nextButton = new Button("");
private final Button closeButton = new Button("×");
private List<WarningItem> warnings = List.of();
private int activeIndex;
private boolean dismissed;
public EditorWarningStrip() {
setAlignment(Pos.CENTER_LEFT);
getStyleClass().add("editor-workspace-warning");
message.setWrapText(true);
message.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(message, Priority.ALWAYS);
counter.getStyleClass().add("editor-workspace-warning-counter");
previousButton.getStyleClass().add("editor-workspace-warning-button");
nextButton.getStyleClass().add("editor-workspace-warning-button");
closeButton.getStyleClass().add("editor-workspace-warning-button");
previousButton.setFocusTraversable(false);
nextButton.setFocusTraversable(false);
closeButton.setFocusTraversable(false);
previousButton.setOnAction(event -> showIndex(activeIndex - 1));
nextButton.setOnAction(event -> showIndex(activeIndex + 1));
closeButton.setOnAction(event -> dismiss());
final var spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
getChildren().addAll(message, spacer, counter, previousButton, nextButton, closeButton);
setVisible(false);
setManaged(false);
}
public void showWarnings(final List<WarningItem> warningItems) {
warnings = List.copyOf(Objects.requireNonNull(warningItems, "warningItems"));
activeIndex = 0;
dismissed = false;
refresh();
}
public void clearWarnings() {
warnings = List.of();
activeIndex = 0;
dismissed = false;
refresh();
}
private void dismiss() {
dismissed = true;
refresh();
}
private void showIndex(final int candidateIndex) {
if (warnings.isEmpty()) {
return;
}
activeIndex = Math.floorMod(candidateIndex, warnings.size());
refresh();
}
private void refresh() {
final boolean visible = !dismissed && !warnings.isEmpty();
setVisible(visible);
setManaged(visible);
if (!visible) {
message.setText("");
counter.setText("");
return;
}
final WarningItem warning = warnings.get(activeIndex);
message.setText(warning.message());
counter.setText(warnings.size() == 1 ? "1/1" : (activeIndex + 1) + "/" + warnings.size());
final boolean canNavigate = warnings.size() > 1;
previousButton.setDisable(!canNavigate);
nextButton.setDisable(!canNavigate);
}
public record WarningItem(String message) {
public WarningItem {
message = Objects.requireNonNull(message, "message");
}
}
}

View File

@ -1,784 +0,0 @@
package p.studio.workspaces.editor;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.input.KeyEvent;
import javafx.scene.control.SplitPane;
import javafx.scene.layout.*;
import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.LineNumberFactory;
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;
import p.studio.vfs.VfsProjectDocument;
import p.studio.vfs.VfsDocumentOpenResult;
import p.studio.vfs.messages.VfsProjectNode;
import p.studio.workspaces.Workspace;
import p.studio.workspaces.WorkspaceId;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.IntFunction;
public final class EditorWorkspace extends Workspace {
private static final int CARET_SCROLL_CONTEXT_LINES = 2;
private static final KeyCombination SAVE_SHORTCUT = new KeyCodeCombination(KeyCode.S, KeyCombination.SHORTCUT_DOWN);
private static final KeyCombination SAVE_ALL_SHORTCUT = new KeyCodeCombination(
KeyCode.S,
KeyCombination.SHORTCUT_DOWN,
KeyCombination.SHIFT_DOWN);
private final BorderPane root = new BorderPane();
private final CodeArea codeArea = new CodeArea();
private final VirtualizedScrollPane<CodeArea> codeScroller = new VirtualizedScrollPane<>(codeArea);
private final Button saveButton = new Button();
private final Button saveAllButton = new Button();
private final EditorWarningStrip warningStrip = new EditorWarningStrip();
private final EditorProjectNavigatorPanel navigatorPanel = new EditorProjectNavigatorPanel();
private final EditorOutlinePanel outlinePanel = new EditorOutlinePanel();
private final EditorHelperPanel helperPanel = new EditorHelperPanel();
private final EditorStatusBar statusBar = new EditorStatusBar();
private final EditorTabStrip tabStrip = new EditorTabStrip();
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<String> activePresentationStylesheets = new ArrayList<>();
private final IntFunction<Node> lineNumberFactory = LineNumberFactory.get(codeArea);
private EditorDocumentScopeGuideModel scopeGuideModel = EditorDocumentScopeGuideModel.empty();
private EditorDocumentScopeGuideModel.ActiveGuides activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
private EditorInlineHintProjection inlineHintProjection = EditorInlineHintProjection.create(
"",
presentationRegistry.resolve("text").highlight(""),
List.of());
private boolean syncingEditor;
private boolean applyingPendingLayoutState;
private int activeGuideParagraph = -1;
private boolean pendingCaretContextRestore;
private Runnable stateChangedAction = () -> { };
private ProjectLocalStudioState.EditorLayoutState pendingEditorLayoutState = ProjectLocalStudioState.EditorLayoutState.defaults();
private SplitPane contentSplit;
private SplitPane leftColumnSplit;
private SplitPane rightColumnSplit;
public EditorWorkspace(
final ProjectReference projectReference,
final VfsProjectDocument vfsProjectDocument,
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);
codeArea.setWrapText(false);
codeArea.textProperty().addListener((ignored, previous, current) -> syncActiveDocumentToVfs(current));
codeArea.caretPositionProperty().addListener((ignored, previous, current) -> {
final int caretOffset = current == null ? 0 : current.intValue();
updateActiveGuides(caretOffset, codeArea.getCurrentParagraph());
refreshStatusBarCaret();
});
codeArea.estimatedScrollYProperty().addListener((ignored, previous, current) ->
restoreCaretScrollContextIfNeeded(previous, current));
codeArea.getStyleClass().add("editor-workspace-code-area");
codeArea.addEventFilter(KeyEvent.KEY_PRESSED, this::guardInlineHintMutation);
codeArea.addEventFilter(KeyEvent.KEY_TYPED, this::guardInlineHintMutation);
root.addEventFilter(KeyEvent.KEY_PRESSED, this::handleWorkspaceShortcuts);
inlineHintChangeSubscription = codeArea.plainTextChanges().subscribe(change -> {
if (syncingEditor) {
return;
}
inlineHintProjection.applyDisplayChange(
change.getPosition(),
change.getRemoved().length(),
change.getInserted().length());
});
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text"));
configureCommandBar();
configureWarning();
showEditorPlaceholder();
navigatorPanel.setRefreshAction(this::refreshNavigator);
navigatorPanel.setRevealActiveFileAction(this::revealActiveFileInNavigator);
navigatorPanel.setFileSelectionAction(this::openNode);
tabStrip.setTabSelectionAction(path -> {
openFileSession.activate(path);
notifyStateChanged();
renderSession();
});
tabStrip.setTabCloseAction(this::requestCloseFile);
root.setCenter(buildLayout());
statusBar.showPlaceholder(presentationRegistry.resolve("text"));
}
@Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; }
@Override public I18n title() { return I18n.WORKSPACE_CODE; }
@Override public Node rootNode() { return root; }
@Override
public void load() {
applyEditorLayoutState(pendingEditorLayoutState);
schedulePendingLayoutReapply();
refreshNavigator();
}
@Override
public void unLoad() {
inlineHintChangeSubscription.unsubscribe();
}
public CodeArea codeArea() { return codeArea; }
public void setStateChangedAction(final Runnable stateChangedAction) {
this.stateChangedAction = Objects.requireNonNull(stateChangedAction, "stateChangedAction");
}
public void restoreProjectLocalState(final ProjectLocalStudioState projectLocalStudioState) {
final ProjectLocalStudioState state = Objects.requireNonNull(projectLocalStudioState, "projectLocalStudioState");
pendingEditorLayoutState = state.shellLayout().editorLayout();
reapplyPendingLayoutState();
schedulePendingLayoutReapply();
restoreEditorState(state.editorRestoration());
}
public void reapplyPendingLayoutState() {
applyEditorLayoutState(pendingEditorLayoutState);
}
public ProjectLocalStudioState.EditorLayoutState captureLayoutState() {
return new ProjectLocalStudioState.EditorLayoutState(
dividerPositions(contentSplit),
dividerPositions(leftColumnSplit),
dividerPositions(rightColumnSplit),
dockPanelState(outlinePanel),
dockPanelState(helperPanel),
navigatorPanel.expandedPathStrings());
}
public ProjectLocalStudioState.EditorRestorationState captureRestorationState() {
return openFileSession.restorationState();
}
private void refreshNavigator() {
navigatorPanel.setSnapshot(vfsProjectDocument.refresh());
}
private void openNode(final VfsProjectNode node) {
final VfsDocumentOpenResult result = vfsProjectDocument.openDocument(node.path());
if (result instanceof VfsDocumentOpenResult.VfsTextDocument textDocument) {
openFile(textDocument);
return;
}
showUnsupportedFileModal(node.path());
}
private void openFile(final VfsDocumentOpenResult.VfsTextDocument textDocument) {
openFileSession.open(bufferFrom(textDocument));
notifyStateChanged();
renderSession();
}
private void renderSession() {
final var activeFile = openFileSession.activeFile();
navigatorPanel.setRevealActiveFileAvailable(activeFile.isPresent());
tabStrip.showOpenFiles(
openFileSession.openFiles(),
activeFile.map(EditorOpenFileBuffer::path).orElse(null));
if (activeFile.isEmpty()) {
showEditorPlaceholder();
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
applyPresentationStylesheets(presentation);
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation);
statusBar.showPlaceholder(presentation);
outlinePanel.showPlaceholder();
return;
}
final var fileBuffer = activeFile.orElseThrow();
final LspAnalyzeDocumentResult analysis = fileBuffer.frontendDocument()
? prometeuLspService.analyzeDocument(new LspAnalyzeDocumentRequest(fileBuffer.path()))
: null;
final EditorDocumentPresentation presentation = presentationRegistry.resolve(
fileBuffer.typeId(),
analysis == null ? null : analysis.semanticPresentation());
final EditorDocumentHighlightingResult highlighting = EditorDocumentHighlightingRouter.route(
fileBuffer,
presentation,
analysis);
scopeGuideModel = guidesFor(fileBuffer, analysis);
applyPresentationStylesheets(presentation);
syncingEditor = true;
try {
inlineHintProjection = EditorInlineHintProjection.create(
fileBuffer.content(),
highlighting.styleSpans(),
highlighting.inlineHints());
codeArea.replaceText(inlineHintProjection.displayText());
codeArea.setStyleSpans(0, inlineHintProjection.displayStyles());
codeArea.moveTo(0);
} finally {
syncingEditor = false;
}
activeGuides = scopeGuideModel.resolveActiveGuides(codeArea.getCaretPosition());
activeGuideParagraph = codeArea.getCurrentParagraph();
refreshParagraphGraphics();
Platform.runLater(() -> {
if (fileBuffer.editable()) {
codeArea.requestFocus();
}
});
codeArea.setEditable(fileBuffer.editable());
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation);
refreshCommandSurfaces(fileBuffer);
statusBar.showFile(projectReference, fileBuffer, presentation, indentationSetup.statusLabel());
refreshStatusBarCaret();
refreshSemanticOutline(fileBuffer, analysis);
}
private void revealActiveFileInNavigator() {
openFileSession.activeFile()
.map(EditorOpenFileBuffer::path)
.ifPresent(navigatorPanel::revealPath);
}
private void showUnsupportedFileModal(final Path path) {
final var alert = new Alert(Alert.AlertType.INFORMATION);
if (root.getScene() != null) {
alert.initOwner(root.getScene().getWindow());
}
alert.setHeaderText(null);
alert.setTitle(p.studio.Container.i18n().text(I18n.CODE_EDITOR_UNSUPPORTED_FILE_TITLE));
alert.setContentText(p.studio.Container.i18n().format(I18n.CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE, path.getFileName()));
alert.showAndWait();
}
private void showEditorPlaceholder() {
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
scopeGuideModel = EditorDocumentScopeGuideModel.empty();
activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
activeGuideParagraph = -1;
refreshParagraphGraphics();
applyPresentationStylesheets(presentation);
syncingEditor = true;
try {
inlineHintProjection = EditorInlineHintProjection.create("", presentation.highlight(""), List.of());
codeArea.replaceText("");
codeArea.setStyleSpans(0, inlineHintProjection.displayStyles());
codeArea.moveTo(0);
} finally {
syncingEditor = false;
}
codeArea.setEditable(false);
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation);
saveButton.setDisable(true);
saveAllButton.setDisable(true);
warningStrip.clearWarnings();
outlinePanel.showPlaceholder();
helperPanel.showPlaceholder();
}
private void applyPresentationStylesheets(final EditorDocumentPresentation presentation) {
root.getStylesheets().removeAll(activePresentationStylesheets);
activePresentationStylesheets.clear();
activePresentationStylesheets.addAll(presentation.stylesheetUrls());
root.getStylesheets().addAll(activePresentationStylesheets);
}
private void refreshParagraphGraphics() {
preserveViewport(() -> codeArea.setParagraphGraphicFactory(paragraphIndex -> EditorDocumentScopeGuideGraphicFactory.create(
lineNumberFactory.apply(paragraphIndex),
paragraphIndex,
scopeGuideModel,
activeGuides)));
}
private VBox buildLayout() {
contentSplit = new SplitPane(buildLeftColumn(), buildRightColumn());
contentSplit.setDividerPositions(0.30);
contentSplit.getStyleClass().add("editor-workspace-split");
observeStatefulSplitPane(contentSplit);
final var layout = new VBox(12, buildCommandBar(), contentSplit, statusBar);
layout.getStyleClass().add("editor-workspace-layout");
VBox.setVgrow(contentSplit, Priority.ALWAYS);
return layout;
}
private SplitPane buildLeftColumn() {
leftColumnSplit = outlinePanel.createBottomDockLayout(navigatorPanel, "editor-workspace-left-split");
final var leftColumn = leftColumnSplit;
leftColumn.getStyleClass().add("editor-workspace-left-column");
observeStatefulSplitPane(leftColumnSplit);
observeDockPane(outlinePanel);
return leftColumn;
}
private VBox buildCenterColumn() {
final var editorViewport = new StackPane(codeScroller);
editorViewport.getStyleClass().add("editor-workspace-editor-viewport");
final var editorSurface = new VBox(0, warningStrip, editorViewport);
editorSurface.getStyleClass().add("editor-workspace-editor-surface");
codeScroller.getStyleClass().add("editor-workspace-code-scroller");
VBox.setVgrow(editorViewport, Priority.ALWAYS);
final var centerColumn = new VBox(12, tabStrip, editorSurface);
centerColumn.getStyleClass().add("editor-workspace-center-column");
VBox.setVgrow(editorSurface, Priority.ALWAYS);
return centerColumn;
}
private SplitPane buildRightColumn() {
rightColumnSplit = helperPanel.createBottomDockLayout(buildCenterColumn(), "editor-workspace-right-split");
observeStatefulSplitPane(rightColumnSplit);
observeDockPane(helperPanel);
return rightColumnSplit;
}
private void configureCommandBar() {
saveButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE));
saveAllButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE_ALL));
saveButton.getStyleClass().addAll("studio-button", "studio-button-primary", "editor-workspace-command-button");
saveAllButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "editor-workspace-command-button");
saveButton.setFocusTraversable(false);
saveAllButton.setFocusTraversable(false);
saveButton.setDisable(true);
saveAllButton.setDisable(true);
saveButton.setOnAction(event -> saveActiveFile());
saveAllButton.setOnAction(event -> saveAllFiles());
}
private void configureWarning() {
warningStrip.clearWarnings();
}
private HBox buildCommandBar() {
final var spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
final var commandBar = new HBox(8, saveButton, saveAllButton, spacer);
commandBar.getStyleClass().add("editor-workspace-command-bar");
return commandBar;
}
private void syncActiveDocumentToVfs(final String content) {
if (syncingEditor) {
return;
}
openFileSession.activeFile()
.filter(EditorOpenFileBuffer::editable)
.ifPresent(activeFile -> {
final String sourceContent = inlineHintProjection.stripDecorations(content);
final VfsDocumentOpenResult.VfsTextDocument updatedDocument = vfsProjectDocument.updateDocument(activeFile.path(), sourceContent);
openFileSession.open(bufferFrom(updatedDocument));
pendingCaretContextRestore = true;
refreshEditableHighlighting(updatedDocument);
refreshEditableSemanticState(updatedDocument);
statusBar.showDocumentFormatting(updatedDocument.lineSeparator(), indentationSetup.statusLabel());
tabStrip.showOpenFiles(
openFileSession.openFiles(),
openFileSession.activeFile().map(EditorOpenFileBuffer::path).orElse(null));
refreshCommandSurfaces(openFileSession.activeFile().orElseThrow());
});
}
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());
preserveViewport(() -> codeArea.setStyleSpans(0, inlineHintProjection.displayStyles()));
}
private void refreshEditableSemanticState(final VfsDocumentOpenResult.VfsTextDocument updatedDocument) {
final EditorOpenFileBuffer fileBuffer = openFileSession.activeFile().orElse(null);
if (fileBuffer == null || !fileBuffer.path().equals(updatedDocument.path())) {
return;
}
if (!updatedDocument.accessContext().frontendDocument()) {
scopeGuideModel = EditorDocumentScopeGuideModel.empty();
activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
activeGuideParagraph = -1;
refreshParagraphGraphics();
outlinePanel.showPlaceholder();
helperPanel.showPlaceholder();
return;
}
final LspAnalyzeDocumentResult analysis = prometeuLspService.analyzeDocument(
new LspAnalyzeDocumentRequest(updatedDocument.path()));
scopeGuideModel = guidesFor(fileBuffer, analysis);
activeGuides = scopeGuideModel.resolveActiveGuides(codeArea.getCaretPosition());
activeGuideParagraph = codeArea.getCurrentParagraph();
refreshParagraphGraphics();
refreshSemanticOutline(fileBuffer, analysis);
}
private void preserveViewport(final Runnable action) {
final int caretPosition = codeArea.getCaretPosition();
final double scrollX = codeArea.estimatedScrollXProperty().getValue();
final double scrollY = codeArea.estimatedScrollYProperty().getValue();
action.run();
codeArea.moveTo(Math.max(0, Math.min(caretPosition, codeArea.getLength())));
Platform.runLater(() -> {
codeArea.scrollXToPixel(scrollX);
codeArea.scrollYToPixel(scrollY);
});
}
private void restoreCaretScrollContextIfNeeded(final Number previous, final Number current) {
if (!pendingCaretContextRestore) {
return;
}
final double previousValue = previous == null ? Double.NaN : previous.doubleValue();
final double currentValue = current == null ? Double.NaN : current.doubleValue();
if (!Double.isFinite(previousValue) || !Double.isFinite(currentValue) || Double.compare(previousValue, currentValue) == 0) {
return;
}
pendingCaretContextRestore = false;
Platform.runLater(() -> codeArea.showParagraphAtTop(
Math.max(0, codeArea.getCurrentParagraph() - CARET_SCROLL_CONTEXT_LINES)));
}
private void saveActiveFile() {
openFileSession.activeFile()
.filter(EditorOpenFileBuffer::saveEnabled)
.ifPresent(activeFile -> {
vfsProjectDocument.saveDocument(activeFile.path());
reloadOpenFilesFromVfs(activeFile.path());
renderSession();
});
}
private void saveAllFiles() {
if (!openFileSession.hasDirtyEditableFiles()) {
return;
}
vfsProjectDocument.saveAllDocuments();
reloadOpenFilesFromVfs(openFileSession.activeFile().map(EditorOpenFileBuffer::path).orElse(null));
renderSession();
}
private void requestCloseFile(final Path path) {
final var fileBuffer = openFileSession.file(path).orElse(null);
if (fileBuffer == null) {
return;
}
if (fileBuffer.dirty() && !confirmDirtyFileClose(fileBuffer)) {
return;
}
openFileSession.close(path);
notifyStateChanged();
renderSession();
}
private boolean confirmDirtyFileClose(final EditorOpenFileBuffer fileBuffer) {
final var alert = new Alert(Alert.AlertType.CONFIRMATION);
if (root.getScene() != null) {
alert.initOwner(root.getScene().getWindow());
}
final var saveButtonType = new ButtonType(
p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_SAVE),
ButtonBar.ButtonData.YES);
final var discardButtonType = new ButtonType(
p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_DISCARD),
ButtonBar.ButtonData.NO);
final var cancelButtonType = new ButtonType(
p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_CANCEL),
ButtonBar.ButtonData.CANCEL_CLOSE);
alert.setTitle(p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_TITLE));
alert.setHeaderText(null);
alert.setContentText(p.studio.Container.i18n().format(
I18n.CODE_EDITOR_CLOSE_DIRTY_MESSAGE,
fileBuffer.tabLabel()));
alert.getButtonTypes().setAll(saveButtonType, discardButtonType, cancelButtonType);
final var result = alert.showAndWait().orElse(cancelButtonType);
if (result == saveButtonType) {
vfsProjectDocument.saveDocument(fileBuffer.path());
return true;
}
if (result == discardButtonType) {
vfsProjectDocument.discardDocument(fileBuffer.path());
return true;
}
return false;
}
private void handleWorkspaceShortcuts(final KeyEvent event) {
if (SAVE_ALL_SHORTCUT.match(event)) {
if (!saveAllButton.isDisabled()) {
saveAllFiles();
}
event.consume();
return;
}
if (SAVE_SHORTCUT.match(event)) {
if (!saveButton.isDisabled()) {
saveActiveFile();
}
event.consume();
}
}
private void reloadOpenFilesFromVfs(final Path activePath) {
final var openPaths = openFileSession.openFiles().stream()
.map(EditorOpenFileBuffer::path)
.toList();
for (final var path : openPaths) {
final var result = vfsProjectDocument.openDocument(path);
if (result instanceof VfsDocumentOpenResult.VfsTextDocument textDocument) {
openFileSession.open(bufferFrom(textDocument));
}
}
if (activePath != null) {
openFileSession.activate(activePath);
}
}
private void refreshCommandSurfaces(final EditorOpenFileBuffer fileBuffer) {
saveButton.setDisable(!fileBuffer.saveEnabled());
saveAllButton.setDisable(!openFileSession.hasDirtyEditableFiles());
warningStrip.clearWarnings();
}
private EditorOpenFileBuffer bufferFrom(final VfsDocumentOpenResult.VfsTextDocument textDocument) {
return new EditorOpenFileBuffer(
textDocument.path(),
textDocument.documentName(),
textDocument.typeId(),
textDocument.content(),
textDocument.lineSeparator(),
textDocument.accessContext().frontendDocument(),
textDocument.accessContext().accessMode(),
textDocument.dirty());
}
private void refreshSemanticOutline(
final EditorOpenFileBuffer fileBuffer,
final LspAnalyzeDocumentResult analysis) {
if (!fileBuffer.frontendDocument() || analysis == null) {
outlinePanel.showPlaceholder();
helperPanel.showPlaceholder();
return;
}
outlinePanel.showSemanticReadResult(fileBuffer.path(), analysis.documentSymbols());
helperPanel.showSemanticReadResult(fileBuffer.path(), analysis.diagnostics());
}
private EditorDocumentScopeGuideModel guidesFor(
final EditorOpenFileBuffer fileBuffer,
final LspAnalyzeDocumentResult analysis) {
if (!fileBuffer.frontendDocument() || analysis == null) {
return EditorDocumentScopeGuideModel.empty();
}
if (!analysis.structuralAnchors().isEmpty()) {
return EditorDocumentScopeGuideModel.fromStructuralAnchors(
fileBuffer.content(),
analysis.structuralAnchors());
}
return EditorDocumentScopeGuideModel.from(fileBuffer.content(), analysis.documentSymbols());
}
private void updateActiveGuides(final int caretOffset, final int currentParagraph) {
final EditorDocumentScopeGuideModel.ActiveGuides next = scopeGuideModel.resolveActiveGuides(caretOffset);
if (Objects.equals(activeGuides, next) && activeGuideParagraph == currentParagraph) {
return;
}
final boolean guidesChanged = !Objects.equals(activeGuides, next);
activeGuides = next;
final boolean paragraphChanged = activeGuideParagraph != currentParagraph;
activeGuideParagraph = currentParagraph;
if (!paragraphChanged && !guidesChanged) {
return;
}
refreshParagraphGraphics();
}
private void refreshStatusBarCaret() {
openFileSession.activeFile()
.filter(EditorOpenFileBuffer::editable)
.ifPresent(ignored -> statusBar.showCaretPosition(
codeArea.getCurrentParagraph() + 1,
codeArea.getCaretColumn() + 1));
}
private void guardInlineHintMutation(final KeyEvent event) {
if (syncingEditor || !codeArea.isEditable()) {
return;
}
final int selectionStart = codeArea.getSelection().getStart();
final int selectionEnd = codeArea.getSelection().getEnd();
if (selectionStart != selectionEnd && inlineHintProjection.touchesRange(selectionStart, selectionEnd)) {
event.consume();
codeArea.moveTo(inlineHintProjection.clampCaret(selectionStart, false));
return;
}
final int caret = codeArea.getCaretPosition();
if (event.getEventType() == KeyEvent.KEY_TYPED) {
if (inlineHintProjection.containsOffset(caret)) {
event.consume();
codeArea.moveTo(inlineHintProjection.clampCaret(caret, true));
}
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));
return;
}
if (event.getCode() == KeyCode.DELETE && inlineHintProjection.containsOffset(caret)) {
event.consume();
codeArea.moveTo(inlineHintProjection.clampCaret(caret, true));
}
}
private void restoreEditorState(final ProjectLocalStudioState.EditorRestorationState editorRestorationState) {
openFileSession.clear();
for (final String pathText : editorRestorationState.openTabPaths()) {
tryRestoreOpenFile(pathText);
}
if (editorRestorationState.activeTabPath() != null) {
try {
openFileSession.activate(Path.of(editorRestorationState.activeTabPath()));
} catch (RuntimeException ignored) {
// Fall back to the currently active restored tab.
}
}
renderSession();
}
private void tryRestoreOpenFile(final String pathText) {
try {
final Path path = Path.of(pathText).toAbsolutePath().normalize();
final VfsDocumentOpenResult result = vfsProjectDocument.openDocument(path);
if (result instanceof VfsDocumentOpenResult.VfsTextDocument textDocument) {
openFileSession.open(bufferFrom(textDocument));
}
} catch (RuntimeException ignored) {
// Invalid or unavailable persisted tabs are skipped and the editor falls back to the remaining state.
}
}
private List<Double> dividerPositions(final SplitPane splitPane) {
if (splitPane == null || splitPane.getDividers().isEmpty()) {
return List.of();
}
return splitPane.getDividers().stream()
.map(SplitPane.Divider::getPosition)
.toList();
}
private void applyDividerPositions(final SplitPane splitPane, final List<Double> positions) {
if (splitPane == null || positions == null || positions.isEmpty()) {
return;
}
final double[] values = positions.stream().mapToDouble(Double::doubleValue).toArray();
splitPane.setDividerPositions(values);
}
private ProjectLocalStudioState.DockPanelState dockPanelState(final p.studio.controls.WorkspaceDockPane dockPane) {
final p.studio.controls.WorkspaceDockPane.DockState state = dockPane.captureDockState();
return new ProjectLocalStudioState.DockPanelState(state.expanded(), state.dividerPosition());
}
private void restoreDockPanelState(
final p.studio.controls.WorkspaceDockPane dockPane,
final ProjectLocalStudioState.DockPanelState dockPanelState) {
if (dockPanelState == null) {
return;
}
final double dividerPosition = dockPanelState.dividerPosition() == null
? dockPane.captureDockState().dividerPosition()
: dockPanelState.dividerPosition();
dockPane.restoreDockState(new p.studio.controls.WorkspaceDockPane.DockState(
dockPanelState.expanded(),
dividerPosition));
}
private void observeStatefulSplitPane(final SplitPane splitPane) {
splitPane.getDividers().forEach(divider ->
divider.positionProperty().addListener((ignored, previous, current) -> {
if (applyingPendingLayoutState) {
return;
}
notifyStateChanged();
}));
splitPane.getDividers().addListener((javafx.collections.ListChangeListener<? super SplitPane.Divider>) change -> {
while (change.next()) {
if (change.wasAdded()) {
change.getAddedSubList().forEach(divider ->
divider.positionProperty().addListener((ignored, previous, current) -> {
if (applyingPendingLayoutState) {
return;
}
notifyStateChanged();
}));
}
}
});
}
private void observeDockPane(final p.studio.controls.WorkspaceDockPane dockPane) {
dockPane.expandedProperty().addListener((ignored, previous, current) -> {
if (applyingPendingLayoutState) {
return;
}
notifyStateChanged();
});
}
private void notifyStateChanged() {
pendingEditorLayoutState = captureLayoutState();
stateChangedAction.run();
}
private void schedulePendingLayoutReapply() {
if (contentSplit == null) {
return;
}
if (contentSplit.getWidth() > 0) {
Platform.runLater(this::reapplyPendingLayoutState);
return;
}
final javafx.beans.value.ChangeListener<Number>[] listenerRef = new javafx.beans.value.ChangeListener[1];
listenerRef[0] = (ignored, previous, current) -> {
if (current == null || current.doubleValue() <= 0) {
return;
}
contentSplit.widthProperty().removeListener(listenerRef[0]);
Platform.runLater(this::reapplyPendingLayoutState);
};
contentSplit.widthProperty().addListener(listenerRef[0]);
}
private void applyEditorLayoutState(final ProjectLocalStudioState.EditorLayoutState editorLayoutState) {
applyingPendingLayoutState = true;
applyDividerPositions(contentSplit, editorLayoutState.contentSplitDividers());
applyDividerPositions(leftColumnSplit, editorLayoutState.leftColumnDividers());
applyDividerPositions(rightColumnSplit, editorLayoutState.rightColumnDividers());
restoreDockPanelState(outlinePanel, editorLayoutState.outlinePanel());
restoreDockPanelState(helperPanel, editorLayoutState.helperPanel());
navigatorPanel.restoreExpandedPaths(editorLayoutState.navigatorExpandedPaths());
Platform.runLater(() -> applyingPendingLayoutState = false);
}
}

View File

@ -1,85 +0,0 @@
package p.studio.workspaces.editor;
import javafx.scene.Node;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.SVGPath;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class EditorWorkspaceIcons {
private static final Pattern SVG_PATH_PATTERN = Pattern.compile("<path[^>]*\\sd=\"([^\"]+)\"");
private static final String FOLDER_PATH = "M1.5 4.5A1.5 1.5 0 0 1 3 3h3.2l1.1 1.4H13A1.5 1.5 0 0 1 14.5 5.9V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12z";
private static final String FILE_PATH = "M4 1.5h5l3 3V13A1.5 1.5 0 0 1 10.5 14.5h-6A1.5 1.5 0 0 1 3 13V3A1.5 1.5 0 0 1 4.5 1.5zm4.7 1.2V5h2.3z";
private static final String COG_PATH = "M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.24-.68-2.64.72-1.96 1.96l.17.31c.446.816.023 1.84-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1c.895.265 1.318 1.289.872 2.105l-.17.31c-.68 1.24.72 2.64 1.96 1.96l.31-.17a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.24.68 2.64-.72 1.96-1.96l-.17-.31a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.68-1.24-.72-2.64-1.96-1.96l-.31.17a1.464 1.464 0 0 1-2.105-.872z";
private static final String REFRESH_PATH = "M8 2.2a5.8 5.8 0 0 1 4.3 1.9V2.8h1.2v3.8H9.7V5.4h1.8A4.6 4.6 0 1 0 12 11h1.3A5.9 5.9 0 1 1 8 2.2z";
private static final String TARGET_PATH = "M7.25 1.5h1.5v1.9a4.85 4.85 0 0 1 3.85 3.85h1.9v1.5h-1.9a4.85 4.85 0 0 1-3.85 3.85v1.9h-1.5v-1.9A4.85 4.85 0 0 1 3.4 8.75H1.5v-1.5h1.9A4.85 4.85 0 0 1 7.25 3.4zm.75 3.25A3.25 3.25 0 1 0 8 11.25 3.25 3.25 0 0 0 8 4.75zm0 1.65a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z";
private EditorWorkspaceIcons() {
}
public static Node folderDefault() {
return icon(FOLDER_PATH, "editor-workspace-icon-folder-default");
}
public static Node folderSource() {
return icon(FOLDER_PATH, "editor-workspace-icon-folder-source");
}
public static Node folderBuild() {
return icon(FOLDER_PATH, "editor-workspace-icon-folder-build");
}
public static Node file() {
return icon(FILE_PATH, "editor-workspace-icon-file");
}
public static Node cog() {
return icon(COG_PATH, "editor-workspace-icon-cog");
}
public static Node refresh() {
return icon(REFRESH_PATH, "editor-workspace-icon-refresh");
}
public static Node target() {
return icon(TARGET_PATH, "editor-workspace-icon-target");
}
public static Node lockClosed() {
return icon(iconPathFromResource("/icons/editor/lock-closed.svg"), "editor-workspace-icon-lock-closed");
}
public static Node lockOpen() {
return icon(iconPathFromResource("/icons/editor/lock-open.svg"), "editor-workspace-icon-lock-open");
}
private static Node icon(final String path, final String toneClass) {
final var shape = new SVGPath();
shape.setContent(path);
shape.getStyleClass().addAll("editor-workspace-icon-shape", toneClass);
final var icon = new StackPane(shape);
icon.getStyleClass().add("editor-workspace-icon");
return icon;
}
private static String iconPathFromResource(final String resourcePath) {
try (InputStream inputStream = EditorWorkspaceIcons.class.getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new IllegalStateException("Missing icon resource: " + resourcePath);
}
final String svg = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
final Matcher matcher = SVG_PATH_PATTERN.matcher(svg);
if (!matcher.find()) {
throw new IllegalStateException("Missing <path d=\"...\"> in icon resource: " + resourcePath);
}
return matcher.group(1);
} catch (IOException exception) {
throw new IllegalStateException("Unable to read icon resource: " + resourcePath, exception);
}
}
}

View File

@ -1,25 +0,0 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
public record EditorDocumentHighlightToken(
String groupName,
Collection<String> styleClasses) {
public EditorDocumentHighlightToken(final String groupName, final String styleClass) {
this(groupName, List.of(styleClass));
}
public EditorDocumentHighlightToken {
groupName = Objects.requireNonNull(groupName, "groupName");
styleClasses = List.copyOf(Objects.requireNonNull(styleClasses, "styleClasses"));
if (groupName.isBlank()) {
throw new IllegalArgumentException("groupName cannot be blank");
}
if (styleClasses.isEmpty()) {
throw new IllegalArgumentException("styleClasses cannot be empty");
}
}
}

View File

@ -1,64 +0,0 @@
package p.studio.workspaces.editor.syntaxhighlight;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;
import p.studio.lsp.dtos.LspHighlightSpanDTO;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
public final class EditorDocumentSemanticHighlighting {
private EditorDocumentSemanticHighlighting() {
}
public static StyleSpans<Collection<String>> highlight(
final String content,
final List<LspHighlightSpanDTO> semanticHighlights) {
final StyleSpansBuilder<Collection<String>> builder = new StyleSpansBuilder<>();
final List<LspHighlightSpanDTO> orderedHighlights = semanticHighlights.stream()
.sorted(Comparator.comparingInt(highlight -> highlight.range().startOffset()))
.toList();
int cursor = 0;
for (final LspHighlightSpanDTO highlight : orderedHighlights) {
final int start = Math.max(cursor, highlight.range().startOffset());
final int end = Math.min(content.length(), highlight.range().endOffset());
if (start > cursor) {
builder.add(Collections.emptyList(), start - cursor);
}
if (end > start) {
builder.add(List.of("editor-semantic-" + highlight.semanticKey()), end - start);
cursor = end;
}
}
if (cursor < content.length()) {
builder.add(Collections.emptyList(), content.length() - cursor);
}
if (content.isEmpty()) {
builder.add(Collections.emptyList(), 0);
}
return builder.create();
}
public static StyleSpans<Collection<String>> overlay(
final StyleSpans<Collection<String>> baseHighlighting,
final StyleSpans<Collection<String>> semanticHighlighting) {
return baseHighlighting.overlay(semanticHighlighting, EditorDocumentSemanticHighlighting::mergeStyles);
}
private static Collection<String> mergeStyles(
final Collection<String> baseStyles,
final Collection<String> semanticStyles) {
if (baseStyles.isEmpty()) {
return semanticStyles;
}
if (semanticStyles.isEmpty()) {
return baseStyles;
}
final LinkedHashSet<String> merged = new LinkedHashSet<>(baseStyles);
merged.addAll(semanticStyles);
return List.copyOf(merged);
}
}

View File

@ -1,59 +0,0 @@
package p.studio.workspaces.editor.syntaxhighlight;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public record EditorDocumentSyntaxHighlighting(
Pattern tokenPattern,
List<EditorDocumentHighlightToken> tokens) {
public EditorDocumentSyntaxHighlighting {
tokens = List.copyOf(Objects.requireNonNull(tokens, "tokens"));
if (tokenPattern == null && !tokens.isEmpty()) {
throw new IllegalArgumentException("plain text highlighting cannot define tokens");
}
if (tokenPattern != null && tokens.isEmpty()) {
throw new IllegalArgumentException("token-based highlighting requires tokens");
}
}
public static EditorDocumentSyntaxHighlighting plainText() { return EditorDocumentSyntaxHighlightingPlainText.PLAIN_TEXT; }
public static EditorDocumentSyntaxHighlighting json() { return EditorDocumentSyntaxHighlightingJson.JSON; }
public static EditorDocumentSyntaxHighlighting bash() { return EditorDocumentSyntaxHighlightingBash.BASH; }
public StyleSpans<Collection<String>> highlight(final String content) {
final StyleSpansBuilder<Collection<String>> builder = new StyleSpansBuilder<>();
if (tokenPattern == null) {
builder.add(Collections.emptyList(), content.length());
return builder.create();
}
final Matcher matcher = tokenPattern.matcher(content);
int lastMatchEnd = 0;
while (matcher.find()) {
builder.add(Collections.emptyList(), matcher.start() - lastMatchEnd);
builder.add(styleClassFor(matcher), matcher.end() - matcher.start());
lastMatchEnd = matcher.end();
}
builder.add(Collections.emptyList(), content.length() - lastMatchEnd);
return builder.create();
}
private Collection<String> styleClassFor(final Matcher matcher) {
for (final EditorDocumentHighlightToken token : tokens) {
if (matcher.group(token.groupName()) != null) {
return token.styleClasses();
}
}
throw new IllegalStateException("matched token without registered style");
}
}

View File

@ -1,28 +0,0 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.List;
import java.util.regex.Pattern;
public class EditorDocumentSyntaxHighlightingBash {
public static final EditorDocumentSyntaxHighlighting BASH = new EditorDocumentSyntaxHighlighting(
Pattern.compile(
"(?<SHEBANG>^#![^\\n]*)"
+ "|(?<COMMENT>(?m)(?<!\\S)#[^\\n]*)"
+ "|(?<STRING>\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')"
+ "|(?<VARIABLE>\\$\\{?[A-Za-z_][A-Za-z0-9_]*}?|\\$[0-9@*#?!$-])"
+ "|(?<TESTBUILTIN>(?m)^[ \\t]*test\\b)"
+ "|(?<COMMAND>(?m)^[ \\t]*[A-Za-z_./-][A-Za-z0-9_./-]*)"
+ "|(?<KEYWORD>\\b(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time)\\b)"
+ "|(?<BUILTIN>\\b(?:echo|printf|read|cd|pwd|export|local|readonly|unset|return|shift|source|trap|exit)\\b)"
+ "|(?<OPERATOR>\\|\\||&&|;;|<<-?|>>|[|&;<>~=(){}\\[\\]])"),
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"),
new EditorDocumentHighlightToken("OPERATOR", "editor-syntax-bash-operator")));
}

View File

@ -1,22 +0,0 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.List;
import java.util.regex.Pattern;
public class EditorDocumentSyntaxHighlightingJson {
public static final EditorDocumentSyntaxHighlighting JSON = new EditorDocumentSyntaxHighlighting(
Pattern.compile(
"(?<KEY>\"(?:\\\\.|[^\"\\\\])*\"(?=\\s*:))"
+ "|(?<STRING>\"(?:\\\\.|[^\"\\\\])*\")"
+ "|(?<NUMBER>-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b)"
+ "|(?<BOOLEAN>\\b(?:true|false)\\b)"
+ "|(?<NULL>\\bnull\\b)"
+ "|(?<PUNCTUATION>[\\{\\}\\[\\],:])"),
List.of(
new EditorDocumentHighlightToken("KEY", "editor-syntax-json-key"),
new EditorDocumentHighlightToken("STRING", "editor-syntax-json-string"),
new EditorDocumentHighlightToken("NUMBER", "editor-syntax-json-number"),
new EditorDocumentHighlightToken("BOOLEAN", "editor-syntax-json-boolean"),
new EditorDocumentHighlightToken("NULL", "editor-syntax-json-null"),
new EditorDocumentHighlightToken("PUNCTUATION", "editor-syntax-json-punctuation")));
}

View File

@ -1,9 +0,0 @@
package p.studio.workspaces.editor.syntaxhighlight;
import java.util.List;
public class EditorDocumentSyntaxHighlightingPlainText {
public static final EditorDocumentSyntaxHighlighting PLAIN_TEXT = new EditorDocumentSyntaxHighlighting(
null,
List.of());
}

View File

@ -1,25 +1,11 @@
package p.studio.projectsessions;
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;
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
import p.studio.lsp.messages.LspDefinitionRequest;
import p.studio.lsp.messages.LspDefinitionResult;
import p.studio.projects.ProjectReference;
import p.studio.vfs.VfsProjectDocument;
import p.studio.vfs.ProjectDocumentVfsFactory;
import p.studio.vfs.VfsDocumentOpenResult;
import p.studio.vfs.messages.VfsProjectContext;
import p.studio.vfs.messages.VfsProjectSnapshot;
import p.studio.vfs.messages.VfsRefreshRequest;
import java.nio.file.Path;
@ -28,14 +14,10 @@ import static org.junit.jupiter.api.Assertions.assertSame;
final class StudioProjectSessionFactoryTest {
@Test
void openCreatesProjectSessionBackedByProjectScopedVfs() {
final RecordingLspFactory lspFactory = new RecordingLspFactory();
final RecordingVfsFactory vfsFactory = new RecordingVfsFactory();
void openCreatesProjectSessionWithoutLegacyEditorServices() {
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final RecordingProjectLocalStudioSetupService setupService = new RecordingProjectLocalStudioSetupService();
final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory(
lspFactory,
vfsFactory,
stateService,
setupService);
final ProjectReference projectReference = new ProjectReference(
@ -48,14 +30,6 @@ final class StudioProjectSessionFactoryTest {
final StudioProjectSession session = sessionFactory.open(projectReference);
assertSame(projectReference, session.projectReference());
assertSame(vfsFactory.vfs, session.projectDocumentVfs());
assertSame(lspFactory.service, session.prometeuLspService());
assertEquals("Example", vfsFactory.capturedContext.projectName());
assertEquals("pbs", vfsFactory.capturedContext.languageId());
assertEquals(projectReference.rootPath().toAbsolutePath().normalize(), vfsFactory.capturedContext.rootPath());
assertEquals("Example", lspFactory.capturedContext.projectName());
assertEquals("pbs", lspFactory.capturedContext.languageId());
assertSame(vfsFactory.vfs, lspFactory.capturedVfs);
assertSame(stateService.loadedState, session.projectLocalStudioState());
assertSame(setupService.loadedSetup, session.projectLocalStudioSetup());
assertEquals("IDLE", session.executionSession().snapshot().state().name());
@ -68,8 +42,7 @@ final class StudioProjectSessionFactoryTest {
private final ProjectLocalStudioState loadedState = new ProjectLocalStudioState(
ProjectLocalStudioState.CURRENT_VERSION,
ProjectLocalStudioState.ShellLayout.defaults(),
new ProjectLocalStudioState.OpenShellState("EDITOR"),
ProjectLocalStudioState.EditorRestorationState.defaults());
new ProjectLocalStudioState.OpenShellState("ASSETS"));
@Override
public ProjectLocalStudioState load(final ProjectReference projectReference) {
@ -90,84 +63,4 @@ final class StudioProjectSessionFactoryTest {
return loadedSetup;
}
}
private static final class RecordingVfsFactory implements ProjectDocumentVfsFactory {
private VfsProjectContext capturedContext;
private final VfsProjectDocument vfs = new NoOpVfsProjectDocument();
@Override
public VfsProjectDocument open(VfsProjectContext projectContext) {
this.capturedContext = projectContext;
return vfs;
}
}
private static final class RecordingLspFactory implements LspServiceFactory {
private LspProjectContext capturedContext;
private VfsProjectDocument capturedVfs;
private final LspService service = new NoOpPrometeuLspService();
@Override
public LspService open(
final LspProjectContext projectContext,
final VfsProjectDocument vfsProjectDocument) {
this.capturedContext = projectContext;
this.capturedVfs = vfsProjectDocument;
return service;
}
}
private static final class NoOpVfsProjectDocument implements VfsProjectDocument {
@Override
public VfsProjectContext projectContext() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectSnapshot snapshot() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectSnapshot refresh() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectSnapshot refresh(VfsRefreshRequest request) {
throw new UnsupportedOperationException();
}
@Override
public VfsDocumentOpenResult openDocument(Path path) {
throw new UnsupportedOperationException();
}
}
private static final class NoOpPrometeuLspService implements LspService {
@Override
public LspProjectContext projectContext() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectDocument projectDocumentVfs() {
throw new UnsupportedOperationException();
}
@Override
public LspSessionStateDTO snapshot() {
throw new UnsupportedOperationException();
}
@Override
public LspAnalyzeDocumentResult analyzeDocument(final LspAnalyzeDocumentRequest request) {
throw new UnsupportedOperationException();
}
@Override
public LspDefinitionResult definition(final LspDefinitionRequest request) {
throw new UnsupportedOperationException();
}
}
}

View File

@ -2,22 +2,10 @@ package p.studio.projectsessions;
import org.junit.jupiter.api.Test;
import p.studio.execution.StudioExecutionSessionService;
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;
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
import p.studio.lsp.messages.LspDefinitionRequest;
import p.studio.lsp.messages.LspDefinitionResult;
import p.studio.projects.ProjectReference;
import p.studio.vfs.VfsProjectDocument;
import p.studio.vfs.VfsDocumentOpenResult;
import p.studio.vfs.messages.VfsProjectContext;
import p.studio.vfs.messages.VfsProjectSnapshot;
import p.studio.vfs.messages.VfsRefreshRequest;
import java.nio.file.Path;
@ -26,14 +14,10 @@ import static org.junit.jupiter.api.Assertions.assertSame;
final class StudioProjectSessionTest {
@Test
void closeDelegatesToUnderlyingServicesOnlyOnce() {
final CountingVfsProjectDocument vfs = new CountingVfsProjectDocument();
final CountingPrometeuLspService lsp = new CountingPrometeuLspService(vfs);
void closeSavesStateOnlyOnce() {
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final StudioProjectSession session = new StudioProjectSession(
projectReference(),
lsp,
vfs,
stateService,
ProjectLocalStudioSetup.defaults(),
new StudioExecutionSessionService(),
@ -42,27 +26,21 @@ final class StudioProjectSessionTest {
session.close();
session.close();
assertEquals(1, lsp.closeCalls);
assertEquals(1, vfs.closeCalls);
assertEquals(1, stateService.saveCalls);
assertEquals(projectReference(), stateService.savedProjectReference);
}
@Test
void replaceProjectLocalStudioStateUpdatesTheSavedSnapshot() {
final CountingVfsProjectDocument vfs = new CountingVfsProjectDocument();
final CountingPrometeuLspService lsp = new CountingPrometeuLspService(vfs);
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final StudioProjectSession session = new StudioProjectSession(
projectReference(),
lsp,
vfs,
stateService,
ProjectLocalStudioSetup.defaults(),
new StudioExecutionSessionService(),
ProjectLocalStudioState.defaults());
final ProjectLocalStudioState nextState = ProjectLocalStudioState.defaults()
.withOpenShellState(new ProjectLocalStudioState.OpenShellState("EDITOR"));
.withOpenShellState(new ProjectLocalStudioState.OpenShellState("ASSETS"));
session.replaceProjectLocalStudioState(nextState);
session.close();
@ -86,77 +64,4 @@ final class StudioProjectSessionTest {
savedState = state;
}
}
private static final class CountingVfsProjectDocument implements VfsProjectDocument {
private int closeCalls;
@Override
public VfsProjectContext projectContext() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectSnapshot snapshot() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectSnapshot refresh() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectSnapshot refresh(VfsRefreshRequest request) {
throw new UnsupportedOperationException();
}
@Override
public VfsDocumentOpenResult openDocument(Path path) {
throw new UnsupportedOperationException();
}
@Override
public void close() {
closeCalls++;
}
}
private static final class CountingPrometeuLspService implements LspService {
private final VfsProjectDocument vfsProjectDocument;
private int closeCalls;
private CountingPrometeuLspService(final VfsProjectDocument vfsProjectDocument) {
this.vfsProjectDocument = vfsProjectDocument;
}
@Override
public LspProjectContext projectContext() {
throw new UnsupportedOperationException();
}
@Override
public VfsProjectDocument projectDocumentVfs() {
return vfsProjectDocument;
}
@Override
public LspSessionStateDTO snapshot() {
throw new UnsupportedOperationException();
}
@Override
public LspAnalyzeDocumentResult analyzeDocument(final LspAnalyzeDocumentRequest request) {
throw new UnsupportedOperationException();
}
@Override
public LspDefinitionResult definition(final LspDefinitionRequest request) {
throw new UnsupportedOperationException();
}
@Override
public void close() {
closeCalls++;
}
}
}

View File

@ -23,22 +23,8 @@ final class ProjectLocalStudioStateServiceTest {
final ProjectLocalStudioState state = new ProjectLocalStudioState(
ProjectLocalStudioState.CURRENT_VERSION,
new ProjectLocalStudioState.ShellLayout(
new ProjectLocalStudioState.EditorLayoutState(
List.of(0.25d),
List.of(0.80d),
List.of(0.70d),
new ProjectLocalStudioState.DockPanelState(true, 0.80d),
new ProjectLocalStudioState.DockPanelState(false, 0.70d),
List.of(
project.rootPath().resolve("src").toString(),
project.rootPath().resolve("src/game").toString())),
new ProjectLocalStudioState.AssetsLayoutState(List.of(0.42d))),
new ProjectLocalStudioState.OpenShellState("EDITOR"),
new ProjectLocalStudioState.EditorRestorationState(
List.of(
project.rootPath().resolve("src/main.pbs").toString(),
project.rootPath().resolve("README.md").toString()),
project.rootPath().resolve("src/main.pbs").toString()));
new ProjectLocalStudioState.OpenShellState("ASSETS"));
service.save(project, state);
@ -72,6 +58,43 @@ final class ProjectLocalStudioStateServiceTest {
assertEquals(ProjectLocalStudioState.defaults(), service.load(project));
}
@Test
void legacyEditorStateLoadsWithAssetsFallbackAndIgnoresEditorFields() throws Exception {
final ProjectLocalStudioStateService service = new ProjectLocalStudioStateService();
final ProjectReference project = project("main");
Files.createDirectories(ProjectStudioPaths.studioRoot(project));
Files.writeString(
ProjectStudioPaths.statePath(project),
"""
{
"version": 1,
"shellLayout": {
"editorLayout": {
"contentSplitDividers": [0.2, 0.8]
},
"assetsLayout": {
"contentSplitDividers": [0.42]
}
},
"openShellState": {
"selectedWorkspaceId": "EDITOR"
},
"editorRestoration": {
"openTabPaths": ["src/main.pbs"],
"activeTabPath": "src/main.pbs"
}
}
""");
assertEquals(
new ProjectLocalStudioState(
ProjectLocalStudioState.CURRENT_VERSION,
new ProjectLocalStudioState.ShellLayout(
new ProjectLocalStudioState.AssetsLayoutState(List.of(0.42d))),
new ProjectLocalStudioState.OpenShellState("ASSETS")),
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);

View File

@ -1,315 +0,0 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import p.studio.lsp.dtos.LspHighlightSpanDTO;
import p.studio.lsp.dtos.LspSemanticPresentationDTO;
import p.studio.lsp.dtos.LspRangeDTO;
import p.studio.lsp.dtos.LspSessionStateDTO;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
import p.studio.vfs.messages.VfsDocumentAccessMode;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class EditorDocumentHighlightingRouterTest {
@Test
void frontendDocumentsUseLspOwnedHighlightsWhenSemanticSpansExist() {
final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer(
Path.of("/tmp/example/src/main.pbs"),
"main.pbs",
"pbs",
"fn main() -> void {}",
"LF",
true,
VfsDocumentAccessMode.READ_ONLY,
false);
final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult(
new LspSessionStateDTO(true, List.of("highlight")),
new LspSemanticPresentationDTO(
List.of("pbs-keyword", "pbs-function"),
List.of("/themes/pbs/semantic-highlighting.css")),
List.of(),
List.of(
new LspHighlightSpanDTO(new LspRangeDTO(0, 2), "pbs-keyword"),
new LspHighlightSpanDTO(new LspRangeDTO(3, 7), "pbs-function")),
List.of(),
List.of(),
List.of(),
List.of());
final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route(
fileBuffer,
registry.resolve("pbs", analysis.semanticPresentation()),
analysis);
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
assertTrue(result.inlineHints().isEmpty());
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
}
@Test
void nonFrontendDocumentsStayOnLocalHighlighting() {
final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer(
Path.of("/tmp/example/prometeu.json"),
"prometeu.json",
"json",
"{\n \"name\": \"Example\"\n}\n",
"LF",
false,
VfsDocumentAccessMode.EDITABLE,
false);
final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult(
new LspSessionStateDTO(true, List.of("highlight")),
new LspSemanticPresentationDTO(List.of(), List.of()),
List.of(),
List.of(new LspHighlightSpanDTO(new LspRangeDTO(0, 1), "fe-punctuation")),
List.of(),
List.of(),
List.of(),
List.of());
final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route(
fileBuffer,
registry.resolve("json"),
analysis);
assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner());
assertTrue(result.inlineHints().isEmpty());
}
@Test
void frontendDocumentsFallbackToLocalPbsSyntaxWhenSemanticPresentationResourcesAreUnavailable() {
final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer(
Path.of("/tmp/example/src/main.pbs"),
"main.pbs",
"pbs",
"fn main() -> void {}",
"LF",
true,
VfsDocumentAccessMode.READ_ONLY,
false);
final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult(
new LspSessionStateDTO(true, List.of("highlight")),
new LspSemanticPresentationDTO(
List.of("pbs-keyword", "pbs-function"),
List.of("/themes/pbs/missing.css")),
List.of(),
List.of(
new LspHighlightSpanDTO(new LspRangeDTO(0, 2), "pbs-keyword"),
new LspHighlightSpanDTO(new LspRangeDTO(3, 7), "pbs-function")),
List.of(),
List.of(),
List.of(),
List.of());
final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route(
fileBuffer,
registry.resolve("pbs", analysis.semanticPresentation()),
analysis);
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
assertTrue(result.inlineHints().isEmpty());
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
}
@Test
void frontendServiceHighlightsProjectToDedicatedServiceCssClass() {
final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer(
Path.of("/tmp/example/src/main.pbs"),
"main.pbs",
"pbs",
"declare service Game {}",
"LF",
true,
VfsDocumentAccessMode.READ_ONLY,
false);
final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult(
new LspSessionStateDTO(true, List.of("highlight")),
new LspSemanticPresentationDTO(
List.of("pbs-service"),
List.of("/themes/pbs/semantic-highlighting.css")),
List.of(),
List.of(new LspHighlightSpanDTO(new LspRangeDTO(16, 20), "pbs-service")),
List.of(),
List.of(),
List.of(),
List.of());
final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route(
fileBuffer,
registry.resolve("pbs", analysis.semanticPresentation()),
analysis);
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
assertTrue(result.inlineHints().isEmpty());
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service"));
}
@Test
void frontendDocumentsKeepLocalSyntaxForRangesWithoutSemanticCoverage() {
final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer(
Path.of("/tmp/example/src/main.pbs"),
"main.pbs",
"pbs",
"fn main() -> void { Game.tick(1); }",
"LF",
true,
VfsDocumentAccessMode.READ_ONLY,
false);
final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult(
new LspSessionStateDTO(true, List.of("highlight")),
new LspSemanticPresentationDTO(
List.of("pbs-keyword", "pbs-service", "pbs-function"),
List.of("/themes/pbs/semantic-highlighting.css")),
List.of(),
List.of(
new LspHighlightSpanDTO(new LspRangeDTO(0, 2), "pbs-keyword"),
new LspHighlightSpanDTO(new LspRangeDTO(3, 7), "pbs-function"),
new LspHighlightSpanDTO(new LspRangeDTO(19, 23), "pbs-service")),
List.of(),
List.of(),
List.of(),
List.of());
final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route(
fileBuffer,
registry.resolve("pbs", analysis.semanticPresentation()),
analysis);
assertEquals(EditorDocumentHighlightOwner.LSP, result.owner());
assertTrue(result.inlineHints().isEmpty());
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-keyword"));
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-service"));
assertTrue(containsStyle(result.styleSpans(), "editor-semantic-pbs-function"));
}
@Test
void frontendDocumentsPreserveTransportedInlineHintsEvenWithoutSemanticHighlightCoverage() {
final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer(
Path.of("/tmp/example/src/main.pbs"),
"main.pbs",
"pbs",
"fn main() -> void { let value = 1; }",
"LF",
true,
VfsDocumentAccessMode.READ_ONLY,
false);
final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult(
new LspSessionStateDTO(true, List.of("highlight")),
new LspSemanticPresentationDTO(
List.of("pbs-function"),
List.of("/themes/pbs/semantic-highlighting.css")),
List.of(),
List.of(),
List.of(new p.studio.lsp.dtos.LspInlineHintDTO(new LspRangeDTO(24, 29), "int", "type")),
List.of(),
List.of(),
List.of());
final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route(
fileBuffer,
registry.resolve("pbs", analysis.semanticPresentation()),
analysis);
assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner());
assertEquals(1, result.inlineHints().size());
assertEquals("int", result.inlineHints().getFirst().label());
}
@Test
void nonFrontendDocumentsDiscardTransportedInlineHints() {
final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
final EditorOpenFileBuffer fileBuffer = new EditorOpenFileBuffer(
Path.of("/tmp/example/prometeu.json"),
"prometeu.json",
"json",
"{\n \"name\": \"Example\"\n}\n",
"LF",
false,
VfsDocumentAccessMode.EDITABLE,
false);
final LspAnalyzeDocumentResult analysis = new LspAnalyzeDocumentResult(
new LspSessionStateDTO(true, List.of("highlight")),
new LspSemanticPresentationDTO(List.of(), List.of()),
List.of(),
List.of(),
List.of(new p.studio.lsp.dtos.LspInlineHintDTO(new LspRangeDTO(0, 1), "ignored", "type")),
List.of(),
List.of(),
List.of());
final EditorDocumentHighlightingResult result = EditorDocumentHighlightingRouter.route(
fileBuffer,
registry.resolve("json"),
analysis);
assertEquals(EditorDocumentHighlightOwner.LOCAL, result.owner());
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<Collection<String>> styleSpans,
final String styleClass) {
for (int index = 0; index < styleSpans.getSpanCount(); index++) {
if (styleSpans.getStyleSpan(index).getStyle().contains(styleClass)) {
return true;
}
}
return false;
}
}

View File

@ -1,60 +0,0 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import p.studio.lsp.dtos.LspSemanticPresentationDTO;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class EditorDocumentPresentationRegistryTest {
private final EditorDocumentPresentationRegistry registry = new EditorDocumentPresentationRegistry();
@Test
void resolvesFrontendTypeIdsToFrontendPresentation() {
final EditorDocumentPresentation presentation = registry.resolve(
"pbs",
new LspSemanticPresentationDTO(
java.util.List.of("pbs-keyword", "pbs-service"),
java.util.List.of("/themes/pbs/semantic-highlighting.css")));
assertEquals("pbs", presentation.styleKey());
assertEquals(java.util.List.of("pbs-keyword", "pbs-service"), presentation.semanticKeys());
assertEquals(1, presentation.stylesheetUrls().size());
assertTrue(presentation.stylesheetUrls().getFirst().endsWith("/themes/pbs/semantic-highlighting.css"));
}
@Test
void missingFrontendResourcesFallbackToFrontendOwnedPresentationResources() {
final EditorDocumentPresentation presentation = registry.resolve(
"pbs",
new LspSemanticPresentationDTO(
java.util.List.of("pbs-keyword"),
java.util.List.of("/themes/pbs/missing.css")));
assertEquals("pbs", presentation.styleKey());
assertEquals(java.util.List.of("pbs-keyword"), presentation.semanticKeys());
assertEquals(1, presentation.stylesheetUrls().size());
assertTrue(presentation.highlight("fn main() -> void {}").getStyleSpan(0).getStyle().isEmpty());
}
@Test
void resolvesJsonTypeIdsToJsonPresentation() {
final EditorDocumentPresentation presentation = registry.resolve("json");
assertEquals("json", presentation.styleKey());
assertEquals(1, presentation.stylesheetUrls().size());
}
@Test
void resolvesBashTypeIdsToBashPresentation() {
final EditorDocumentPresentation presentation = registry.resolve("bash");
assertEquals("bash", presentation.styleKey());
assertEquals(1, presentation.stylesheetUrls().size());
}
@Test
void fallsBackToTextPresentationForUnknownTypeIds() {
assertEquals("text", registry.resolve("markdown").styleKey());
}
}

View File

@ -1,166 +0,0 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import p.studio.lsp.dtos.LspRangeDTO;
import p.studio.lsp.dtos.LspStructuralAnchorDTO;
import p.studio.lsp.dtos.LspSymbolDTO;
import p.studio.lsp.messages.LspSymbolKind;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
final class EditorDocumentScopeGuideModelTest {
@Test
void selectsActiveScopeAsSmallestContainingRange() {
final String content = """
struct Hero {
fn attack() {
let value = 1
}
}
enum Kind {
Unit
}
""";
final LspSymbolDTO attack = symbol(content, "fn attack()", "}", LspSymbolKind.METHOD, List.of());
final LspSymbolDTO hero = new LspSymbolDTO(
"struct Hero",
LspSymbolKind.STRUCT,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(
content.indexOf("struct Hero"),
content.indexOf("\n\nenum Kind")),
List.of(attack));
final LspSymbolDTO kind = symbol(content, "enum Kind", "}\n", LspSymbolKind.ENUM, List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero, kind));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("let value"));
assertEquals(content.indexOf("fn attack()"), guides.activeScope().startOffset());
assertEquals(content.indexOf("struct Hero"), guides.activeContainer().startOffset());
}
@Test
void omitsActiveContainerWhenScopeHasNoParent() {
final String content = """
struct Hero {
hp: Int
}
""";
final LspSymbolDTO symbol = new LspSymbolDTO(
"struct Hero",
LspSymbolKind.STRUCT,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(0, content.length()),
List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("hp"));
assertEquals(content.indexOf("struct Hero"), guides.activeScope().startOffset());
assertNull(guides.activeContainer());
}
@Test
void exposesAtMostTwoActiveSegmentsPerLine() {
final String content = """
struct Hero {
fn attack() {
let value = 1
}
}
""";
final LspSymbolDTO attack = symbol(content, "fn attack()", "}", LspSymbolKind.METHOD, List.of());
final LspSymbolDTO hero = new LspSymbolDTO(
"struct Hero",
LspSymbolKind.STRUCT,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(0, content.length()),
List.of(attack));
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("let value"));
assertSegments(model, 1, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:START");
assertSegments(model, 2, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:CONTINUE");
assertSegments(model, 3, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:END");
}
@Test
void ignoresSingleLineAndUnknownSymbols() {
final String content = """
if ready {
value()
}
""";
final LspSymbolDTO symbol = new LspSymbolDTO(
"if ready",
LspSymbolKind.UNKNOWN,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(0, content.length()),
List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol));
assertEquals(EditorDocumentScopeGuideModel.ActiveGuides.empty(), model.resolveActiveGuides(content.indexOf("value")));
}
@Test
void structuralAnchorsDriveGuideStartAndEndLines() {
final String content = """
fn main(
value: int
) -> void
{
helper();
}
""";
final LspStructuralAnchorDTO anchor = new LspStructuralAnchorDTO(
new LspRangeDTO(content.indexOf("fn main"), content.length()),
new LspRangeDTO(content.indexOf("{"), content.indexOf("{") + 1),
new LspRangeDTO(content.indexOf("}"), content.indexOf("}") + 1),
List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.fromStructuralAnchors(content, List.of(anchor));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("helper"));
assertSegments(model, 0, guides);
assertSegments(model, 1, guides);
assertSegments(model, 2, guides);
assertSegments(model, 3, guides, "ACTIVE_SCOPE:START");
assertSegments(model, 4, guides, "ACTIVE_SCOPE:CONTINUE");
assertSegments(model, 5, guides, "ACTIVE_SCOPE:END");
}
private static LspSymbolDTO symbol(
final String content,
final String startMarker,
final String endMarker,
final LspSymbolKind kind,
final List<LspSymbolDTO> children) {
final int start = content.indexOf(startMarker);
final int end = content.indexOf(endMarker, start) + endMarker.length();
return new LspSymbolDTO(
startMarker,
kind,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(start, end),
children);
}
private static void assertSegments(
final EditorDocumentScopeGuideModel model,
final int line,
final EditorDocumentScopeGuideModel.ActiveGuides guides,
final String... expectedKinds) {
assertEquals(
List.of(expectedKinds),
model.segmentsForLine(line, guides).stream()
.map(segment -> segment.role().name() + ":" + segment.kind().name())
.toList());
}
}

View File

@ -1,44 +0,0 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import p.studio.lsp.dtos.LspInlineHintDTO;
import p.studio.lsp.dtos.LspRangeDTO;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class EditorInlineHintLayoutTest {
@Test
void layoutCreatesDecorativePlacementsForHintsWithResolvedAnchors() {
final var hint = new LspInlineHintDTO(new LspRangeDTO(10, 15), "int", "type");
final var placements = EditorInlineHintLayout.layout(
List.of(hint),
ignored -> Optional.of(new EditorInlineHintLayout.AnchorBounds(20.0d, 8.0d, 40.0d, 16.0d)));
assertEquals(1, placements.size());
assertEquals(": int", placements.getFirst().displayText());
assertEquals("editor-inline-hint-type", placements.getFirst().categoryStyleClass());
assertEquals(46.0d, placements.getFirst().screenX());
assertEquals(8.0d, placements.getFirst().screenY());
assertEquals(16.0d, placements.getFirst().anchorHeight());
}
@Test
void layoutKeepsValidHintsWhenOnlySomeAnchorsResolve() {
final var stable = new LspInlineHintDTO(new LspRangeDTO(10, 15), "int", "type");
final var missing = new LspInlineHintDTO(new LspRangeDTO(30, 35), "Player", "type");
final var placements = EditorInlineHintLayout.layout(
List.of(stable, missing),
hint -> hint == stable
? Optional.of(new EditorInlineHintLayout.AnchorBounds(12.0d, 4.0d, 28.0d, 14.0d))
: Optional.empty());
assertEquals(1, placements.size());
assertEquals(stable, placements.getFirst().inlineHint());
assertEquals(": int", placements.getFirst().displayText());
}
}

View File

@ -1,140 +0,0 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import p.studio.vfs.messages.VfsDocumentAccessMode;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class EditorOpenFileSessionTest {
@Test
void openAddsNewFileAndMarksItActive() {
final var session = new EditorOpenFileSession();
final var file = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "fn main(): void\n");
session.open(file);
assertEquals(1, session.openFiles().size());
assertEquals(file.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
}
@Test
void openDoesNotDuplicateExistingTab() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "b");
session.open(first);
session.open(second);
assertEquals(1, session.openFiles().size());
assertEquals(first.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
assertEquals("b", session.activeFile().orElseThrow().content());
}
@Test
void activateSwitchesTheActiveTabWithinTheCurrentSession() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("src/other.pbs"), "other.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "b");
session.open(first);
session.open(second);
session.activate(first.path());
assertEquals(first.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
}
@Test
void reportsSaveStateForDirtyEditableFilesOnly() {
final var session = new EditorOpenFileSession();
session.open(fileBuffer(Path.of("notes.txt"), "notes.txt", VfsDocumentAccessMode.EDITABLE, true, false, "alpha"));
assertTrue(session.hasSaveableActiveFile());
assertTrue(session.hasDirtyEditableFiles());
}
@Test
void frontendEditableFilesParticipateInSaveStateWhenDirty() {
final var session = new EditorOpenFileSession();
session.open(fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, true, true, "fn main(): void"));
assertTrue(session.hasSaveableActiveFile());
assertTrue(session.hasDirtyEditableFiles());
assertTrue(session.activeFile().orElseThrow().frontendDocument());
assertTrue(session.activeFile().orElseThrow().editable());
}
@Test
void closeRemovesInactiveTabWithoutChangingTheActiveTab() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("README.md"), "README.md", VfsDocumentAccessMode.EDITABLE, false, false, "b");
session.open(first);
session.open(second);
session.close(first.path());
assertEquals(1, session.openFiles().size());
assertEquals(second.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
}
@Test
void closePromotesTheNextTabWhenTheActiveTabIsClosed() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("README.md"), "README.md", VfsDocumentAccessMode.EDITABLE, false, false, "b");
final var third = fileBuffer(Path.of("notes.txt"), "notes.txt", VfsDocumentAccessMode.EDITABLE, false, false, "c");
session.open(first);
session.open(second);
session.open(third);
session.activate(second.path());
session.close(second.path());
assertEquals(List.of(
first.path().toAbsolutePath().normalize(),
third.path().toAbsolutePath().normalize()),
session.openFiles().stream().map(EditorOpenFileBuffer::path).toList());
assertEquals(third.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
}
@Test
void exportsRestorationStateFromOpenTabsAndActiveTab() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("README.md"), "README.md", VfsDocumentAccessMode.EDITABLE, false, false, "b");
session.open(first);
session.open(second);
session.activate(first.path());
final var restorationState = session.restorationState();
assertEquals(List.of(
first.path().toAbsolutePath().normalize().toString(),
second.path().toAbsolutePath().normalize().toString()), restorationState.openTabPaths());
assertEquals(first.path().toAbsolutePath().normalize().toString(), restorationState.activeTabPath());
}
private EditorOpenFileBuffer fileBuffer(
final Path path,
final String tabLabel,
final VfsDocumentAccessMode accessMode,
final boolean dirty,
final boolean frontendDocument,
final String content) {
return new EditorOpenFileBuffer(
path,
tabLabel,
frontendDocument ? "pbs" : "text",
content,
"LF",
frontendDocument,
accessMode,
dirty);
}
}