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

View File

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

View File

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

View File

@ -1,60 +1,36 @@
package p.studio.projectsessions; package p.studio.projectsessions;
import p.studio.execution.StudioExecutionSessionService; 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.ProjectLocalStudioSetupService;
import p.studio.projectstate.ProjectLocalStudioStateService; import p.studio.projectstate.ProjectLocalStudioStateService;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.vfs.VfsProjectDocument;
import p.studio.vfs.ProjectDocumentVfsFactory;
import java.util.Objects; import java.util.Objects;
public final class StudioProjectSessionFactory { public final class StudioProjectSessionFactory {
private final LspServiceFactory lspServiceFactory;
private final ProjectDocumentVfsFactory projectDocumentVfsFactory;
private final ProjectLocalStudioStateService projectLocalStudioStateService; private final ProjectLocalStudioStateService projectLocalStudioStateService;
private final ProjectLocalStudioSetupService projectLocalStudioSetupService; private final ProjectLocalStudioSetupService projectLocalStudioSetupService;
public StudioProjectSessionFactory( public StudioProjectSessionFactory() {
final LspServiceFactory lspServiceFactory,
final ProjectDocumentVfsFactory projectDocumentVfsFactory) {
this( this(
lspServiceFactory,
projectDocumentVfsFactory,
new ProjectLocalStudioStateService(), new ProjectLocalStudioStateService(),
new ProjectLocalStudioSetupService()); new ProjectLocalStudioSetupService());
} }
StudioProjectSessionFactory( StudioProjectSessionFactory(
final LspServiceFactory lspServiceFactory,
final ProjectDocumentVfsFactory projectDocumentVfsFactory,
final ProjectLocalStudioStateService projectLocalStudioStateService, final ProjectLocalStudioStateService projectLocalStudioStateService,
final ProjectLocalStudioSetupService projectLocalStudioSetupService) { final ProjectLocalStudioSetupService projectLocalStudioSetupService) {
this.lspServiceFactory = Objects.requireNonNull(lspServiceFactory, "prometeuLspServiceFactory");
this.projectDocumentVfsFactory = Objects.requireNonNull(projectDocumentVfsFactory, "projectDocumentVfsFactory");
this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService"); this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService");
this.projectLocalStudioSetupService = Objects.requireNonNull(projectLocalStudioSetupService, "projectLocalStudioSetupService"); this.projectLocalStudioSetupService = Objects.requireNonNull(projectLocalStudioSetupService, "projectLocalStudioSetupService");
} }
public StudioProjectSession open(final ProjectReference projectReference) { public StudioProjectSession open(final ProjectReference projectReference) {
final ProjectReference target = Objects.requireNonNull(projectReference, "projectReference"); final ProjectReference target = Objects.requireNonNull(projectReference, "projectReference");
final VfsProjectDocument vfsProjectDocument = projectDocumentVfsFactory.open(target.toVfsProjectContext());
return new StudioProjectSession( return new StudioProjectSession(
target, target,
lspServiceFactory.open(lspProjectContext(target), vfsProjectDocument),
vfsProjectDocument,
projectLocalStudioStateService, projectLocalStudioStateService,
projectLocalStudioSetupService.load(target), projectLocalStudioSetupService.load(target),
new StudioExecutionSessionService(), new StudioExecutionSessionService(),
projectLocalStudioStateService.load(target)); 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 com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record ProjectLocalStudioState( public record ProjectLocalStudioState(
int version, int version,
ShellLayout shellLayout, ShellLayout shellLayout,
OpenShellState openShellState, OpenShellState openShellState) {
EditorRestorationState editorRestoration) {
public static final int CURRENT_VERSION = 1; public static final int CURRENT_VERSION = 1;
public ProjectLocalStudioState { public ProjectLocalStudioState {
version = version <= 0 ? CURRENT_VERSION : version; version = version <= 0 ? CURRENT_VERSION : version;
shellLayout = shellLayout == null ? ShellLayout.defaults() : shellLayout.normalize(); shellLayout = shellLayout == null ? ShellLayout.defaults() : shellLayout.normalize();
openShellState = openShellState == null ? OpenShellState.defaults() : openShellState.normalize(); openShellState = openShellState == null ? OpenShellState.defaults() : openShellState.normalize();
editorRestoration = editorRestoration == null ? EditorRestorationState.defaults() : editorRestoration.normalize();
} }
public static ProjectLocalStudioState defaults() { public static ProjectLocalStudioState defaults() {
return new ProjectLocalStudioState( return new ProjectLocalStudioState(
CURRENT_VERSION, CURRENT_VERSION,
ShellLayout.defaults(), ShellLayout.defaults(),
OpenShellState.defaults(), OpenShellState.defaults());
EditorRestorationState.defaults());
} }
public ProjectLocalStudioState withShellLayout(final ShellLayout nextShellLayout) { 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) { public ProjectLocalStudioState withOpenShellState(final OpenShellState nextOpenShellState) {
return new ProjectLocalStudioState(version, shellLayout, nextOpenShellState, editorRestoration); return new ProjectLocalStudioState(version, shellLayout, nextOpenShellState);
}
public ProjectLocalStudioState withEditorRestoration(final EditorRestorationState nextEditorRestoration) {
return new ProjectLocalStudioState(version, shellLayout, openShellState, nextEditorRestoration);
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record ShellLayout(EditorLayoutState editorLayout, AssetsLayoutState assetsLayout) { public record ShellLayout(AssetsLayoutState assetsLayout) {
public ShellLayout { public ShellLayout {
editorLayout = editorLayout == null ? EditorLayoutState.defaults() : editorLayout.normalize();
assetsLayout = assetsLayout == null ? AssetsLayoutState.defaults() : assetsLayout.normalize(); assetsLayout = assetsLayout == null ? AssetsLayoutState.defaults() : assetsLayout.normalize();
} }
public static ShellLayout defaults() { public static ShellLayout defaults() {
return new ShellLayout(EditorLayoutState.defaults(), AssetsLayoutState.defaults()); return new ShellLayout(AssetsLayoutState.defaults());
} }
private ShellLayout normalize() { 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 record OpenShellState(String selectedWorkspaceId) {
public OpenShellState { public OpenShellState {
selectedWorkspaceId = normalizeText(selectedWorkspaceId); selectedWorkspaceId = normalizeText(selectedWorkspaceId);
if ("EDITOR".equals(selectedWorkspaceId)) {
selectedWorkspaceId = "ASSETS";
}
} }
public static OpenShellState defaults() { 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) { private static String normalizeText(final String value) {
if (value == null) { if (value == null) {
return null; return null;
@ -169,20 +89,6 @@ public record ProjectLocalStudioState(
return trimmed.isEmpty() ? null : trimmed; 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) { private static List<Double> normalizeDividerPositions(final List<Double> values) {
if (values == null || values.isEmpty()) { if (values == null || values.isEmpty()) {
return List.of(); return List.of();
@ -196,11 +102,4 @@ public record ProjectLocalStudioState(
} }
return List.copyOf(normalized); 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.assets.AssetWorkspace;
import p.studio.workspaces.builder.ShipperWorkspace; import p.studio.workspaces.builder.ShipperWorkspace;
import p.studio.workspaces.debug.DebugWorkspace; import p.studio.workspaces.debug.DebugWorkspace;
import p.studio.workspaces.editor.EditorWorkspace;
import java.util.List; import java.util.List;
@ -24,7 +23,6 @@ public final class MainView extends BorderPane {
private final ProjectReference projectReference; private final ProjectReference projectReference;
private final StudioProjectSession projectSession; private final StudioProjectSession projectSession;
private final AssetWorkspace assetWorkspace; private final AssetWorkspace assetWorkspace;
private final EditorWorkspace editorWorkspace;
private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail; private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail;
private final StudioPlayStopCoordinator playStopCoordinator; private final StudioPlayStopCoordinator playStopCoordinator;
private boolean initializing; private boolean initializing;
@ -50,23 +48,14 @@ public final class MainView extends BorderPane {
assetWorkspace = new AssetWorkspace(projectReference); assetWorkspace = new AssetWorkspace(projectReference);
host.register(assetWorkspace); host.register(assetWorkspace);
editorWorkspace = new EditorWorkspace(
projectReference,
projectSession.projectDocumentVfs(),
projectSession.prometeuLspService(),
projectSetup.editorIndentation());
host.register(editorWorkspace);
assetWorkspace.restoreProjectLocalState(persistedState); assetWorkspace.restoreProjectLocalState(persistedState);
editorWorkspace.restoreProjectLocalState(persistedState);
assetWorkspace.setStateChangedAction(this::persistProjectLocalState); assetWorkspace.setStateChangedAction(this::persistProjectLocalState);
editorWorkspace.setStateChangedAction(this::persistProjectLocalState);
host.register(new DebugWorkspace(projectReference, projectSession.executionSession())); host.register(new DebugWorkspace(projectReference, projectSession.executionSession()));
host.register(new ShipperWorkspace(projectReference)); host.register(new ShipperWorkspace(projectReference));
workspaceRail = new StudioWorkspaceRailControl<>( workspaceRail = new StudioWorkspaceRailControl<>(
List.of( List.of(
new StudioWorkspaceRailItem<>(WorkspaceId.ASSETS, "📦", Container.i18n().bind(I18n.WORKSPACE_ASSETS)), 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.DEBUG, "🎮", Container.i18n().bind(I18n.WORKSPACE_DEBUG)),
new StudioWorkspaceRailItem<>(WorkspaceId.SHIPPER, "⚙️", Container.i18n().bind(I18n.WORKSPACE_SHIPPER)) new StudioWorkspaceRailItem<>(WorkspaceId.SHIPPER, "⚙️", Container.i18n().bind(I18n.WORKSPACE_SHIPPER))
), ),
@ -102,14 +91,11 @@ public final class MainView extends BorderPane {
projectSession.replaceProjectLocalStudioState( projectSession.replaceProjectLocalStudioState(
current.withOpenShellState(new ProjectLocalStudioState.OpenShellState(selectedWorkspace.name())) current.withOpenShellState(new ProjectLocalStudioState.OpenShellState(selectedWorkspace.name()))
.withShellLayout(new ProjectLocalStudioState.ShellLayout( .withShellLayout(new ProjectLocalStudioState.ShellLayout(
editorWorkspace.captureLayoutState(), assetWorkspace.captureLayoutState())));
assetWorkspace.captureLayoutState()))
.withEditorRestoration(editorWorkspace.captureRestorationState()));
} }
public void reapplyProjectLocalLayout() { public void reapplyProjectLocalLayout() {
assetWorkspace.reapplyPendingLayoutState(); assetWorkspace.reapplyPendingLayoutState();
editorWorkspace.reapplyPendingLayoutState();
} }
public void close() { public void close() {

View File

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

View File

@ -1,7 +1,6 @@
package p.studio.workspaces; package p.studio.workspaces;
public enum WorkspaceId { public enum WorkspaceId {
EDITOR,
ASSETS, ASSETS,
SHIPPER, SHIPPER,
DEBUG 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; package p.studio.projectsessions;
import org.junit.jupiter.api.Test; 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.ProjectLocalStudioSetup;
import p.studio.projectstate.ProjectLocalStudioSetupService; import p.studio.projectstate.ProjectLocalStudioSetupService;
import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projectstate.ProjectLocalStudioStateService; 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.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; import java.nio.file.Path;
@ -28,14 +14,10 @@ import static org.junit.jupiter.api.Assertions.assertSame;
final class StudioProjectSessionFactoryTest { final class StudioProjectSessionFactoryTest {
@Test @Test
void openCreatesProjectSessionBackedByProjectScopedVfs() { void openCreatesProjectSessionWithoutLegacyEditorServices() {
final RecordingLspFactory lspFactory = new RecordingLspFactory();
final RecordingVfsFactory vfsFactory = new RecordingVfsFactory();
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService(); final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final RecordingProjectLocalStudioSetupService setupService = new RecordingProjectLocalStudioSetupService(); final RecordingProjectLocalStudioSetupService setupService = new RecordingProjectLocalStudioSetupService();
final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory( final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory(
lspFactory,
vfsFactory,
stateService, stateService,
setupService); setupService);
final ProjectReference projectReference = new ProjectReference( final ProjectReference projectReference = new ProjectReference(
@ -48,14 +30,6 @@ final class StudioProjectSessionFactoryTest {
final StudioProjectSession session = sessionFactory.open(projectReference); final StudioProjectSession session = sessionFactory.open(projectReference);
assertSame(projectReference, session.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(stateService.loadedState, session.projectLocalStudioState());
assertSame(setupService.loadedSetup, session.projectLocalStudioSetup()); assertSame(setupService.loadedSetup, session.projectLocalStudioSetup());
assertEquals("IDLE", session.executionSession().snapshot().state().name()); assertEquals("IDLE", session.executionSession().snapshot().state().name());
@ -68,8 +42,7 @@ final class StudioProjectSessionFactoryTest {
private final ProjectLocalStudioState loadedState = new ProjectLocalStudioState( private final ProjectLocalStudioState loadedState = new ProjectLocalStudioState(
ProjectLocalStudioState.CURRENT_VERSION, ProjectLocalStudioState.CURRENT_VERSION,
ProjectLocalStudioState.ShellLayout.defaults(), ProjectLocalStudioState.ShellLayout.defaults(),
new ProjectLocalStudioState.OpenShellState("EDITOR"), new ProjectLocalStudioState.OpenShellState("ASSETS"));
ProjectLocalStudioState.EditorRestorationState.defaults());
@Override @Override
public ProjectLocalStudioState load(final ProjectReference projectReference) { public ProjectLocalStudioState load(final ProjectReference projectReference) {
@ -90,84 +63,4 @@ final class StudioProjectSessionFactoryTest {
return loadedSetup; 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 org.junit.jupiter.api.Test;
import p.studio.execution.StudioExecutionSessionService; 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.ProjectLocalStudioSetup;
import p.studio.projectstate.ProjectLocalStudioState; import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projectstate.ProjectLocalStudioStateService; 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.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; import java.nio.file.Path;
@ -26,14 +14,10 @@ import static org.junit.jupiter.api.Assertions.assertSame;
final class StudioProjectSessionTest { final class StudioProjectSessionTest {
@Test @Test
void closeDelegatesToUnderlyingServicesOnlyOnce() { void closeSavesStateOnlyOnce() {
final CountingVfsProjectDocument vfs = new CountingVfsProjectDocument();
final CountingPrometeuLspService lsp = new CountingPrometeuLspService(vfs);
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService(); final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final StudioProjectSession session = new StudioProjectSession( final StudioProjectSession session = new StudioProjectSession(
projectReference(), projectReference(),
lsp,
vfs,
stateService, stateService,
ProjectLocalStudioSetup.defaults(), ProjectLocalStudioSetup.defaults(),
new StudioExecutionSessionService(), new StudioExecutionSessionService(),
@ -42,27 +26,21 @@ final class StudioProjectSessionTest {
session.close(); session.close();
session.close(); session.close();
assertEquals(1, lsp.closeCalls);
assertEquals(1, vfs.closeCalls);
assertEquals(1, stateService.saveCalls); assertEquals(1, stateService.saveCalls);
assertEquals(projectReference(), stateService.savedProjectReference); assertEquals(projectReference(), stateService.savedProjectReference);
} }
@Test @Test
void replaceProjectLocalStudioStateUpdatesTheSavedSnapshot() { void replaceProjectLocalStudioStateUpdatesTheSavedSnapshot() {
final CountingVfsProjectDocument vfs = new CountingVfsProjectDocument();
final CountingPrometeuLspService lsp = new CountingPrometeuLspService(vfs);
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService(); final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final StudioProjectSession session = new StudioProjectSession( final StudioProjectSession session = new StudioProjectSession(
projectReference(), projectReference(),
lsp,
vfs,
stateService, stateService,
ProjectLocalStudioSetup.defaults(), ProjectLocalStudioSetup.defaults(),
new StudioExecutionSessionService(), new StudioExecutionSessionService(),
ProjectLocalStudioState.defaults()); ProjectLocalStudioState.defaults());
final ProjectLocalStudioState nextState = ProjectLocalStudioState.defaults() final ProjectLocalStudioState nextState = ProjectLocalStudioState.defaults()
.withOpenShellState(new ProjectLocalStudioState.OpenShellState("EDITOR")); .withOpenShellState(new ProjectLocalStudioState.OpenShellState("ASSETS"));
session.replaceProjectLocalStudioState(nextState); session.replaceProjectLocalStudioState(nextState);
session.close(); session.close();
@ -86,77 +64,4 @@ final class StudioProjectSessionTest {
savedState = state; 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( final ProjectLocalStudioState state = new ProjectLocalStudioState(
ProjectLocalStudioState.CURRENT_VERSION, ProjectLocalStudioState.CURRENT_VERSION,
new ProjectLocalStudioState.ShellLayout( 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.AssetsLayoutState(List.of(0.42d))),
new ProjectLocalStudioState.OpenShellState("EDITOR"), new ProjectLocalStudioState.OpenShellState("ASSETS"));
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()));
service.save(project, state); service.save(project, state);
@ -72,6 +58,43 @@ final class ProjectLocalStudioStateServiceTest {
assertEquals(ProjectLocalStudioState.defaults(), service.load(project)); 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) { private ProjectReference project(final String name) {
final Path projectRoot = tempDir.resolve(name); final Path projectRoot = tempDir.resolve(name);
return new ProjectReference("Main", "1.0.0", "pbs", 1, projectRoot); 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);
}
}