implements PLN-0062
This commit is contained in:
parent
85d7211b3c
commit
cebef90118
@ -1,4 +1,5 @@
|
||||
{"type":"meta","next_id":{"DSC":31,"AGD":34,"DEC":31,"PLN":62,"LSN":47,"CLSN":1}}
|
||||
{"type":"meta","next_id":{"DSC":32,"AGD":35,"DEC":32,"PLN":65,"LSN":47,"CLSN":1}}
|
||||
{"type":"discussion","id":"DSC-0031","status":"in_progress","ticket":"studio-editor-workspace-cleanup","title":"Limpeza completa do Workspace Editor do Studio e remoção dos acoplamentos legados","created_at":"2026-05-05","updated_at":"2026-05-05","tags":["studio","editor","cleanup","vfs","lsp","migration"],"agendas":[{"id":"AGD-0034","file":"AGD-0034-vscode-editor-migration-feasibility.md","status":"accepted","created_at":"2026-05-05","updated_at":"2026-05-05"}],"decisions":[{"id":"DEC-0031","file":"DEC-0031-studio-editor-workspace-cleanup.md","status":"in_progress","created_at":"2026-05-05","updated_at":"2026-05-05","ref_agenda":"AGD-0034"}],"plans":[{"id":"PLN-0062","file":"PLN-0062-shell-session-and-state-cleanup-after-editor-removal.md","status":"done","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0063","file":"PLN-0063-legacy-lsp-and-vfs-module-removal-with-runtime-preservation.md","status":"open","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0064","file":"PLN-0064-normative-test-and-lesson-cleanup-for-editor-stack-removal.md","status":"open","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]}],"lessons":[]}
|
||||
{"type":"discussion","id":"DSC-0030","status":"done","ticket":"studio-scene-pack-runtime-binary-contract","title":"Studio scene pack contract for runtime SCENE binary payload","created_at":"2026-04-24","updated_at":"2026-05-01","tags":["studio","packer","runtime","scene","asset-pack","binary-format","tiled"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0046","file":"discussion/lessons/DSC-0030-studio-scene-pack-runtime-binary-contract/LSN-0046-canonical-scene-owns-editorial-truth-while-pack-stays-request-driven.md","status":"done","created_at":"2026-05-01","updated_at":"2026-05-01"}]}
|
||||
{"type":"discussion","id":"DSC-0029","status":"done","ticket":"studio-frame-composer-syscall-and-sprite-alignment","title":"Studio Alignment with Runtime FrameComposer Syscalls and Sprite Composition","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["studio","compiler","pbs","stdlib","runtime-alignment","abi","syscall","frame-composer","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0041","file":"discussion/lessons/DSC-0029-studio-frame-composer-syscall-and-sprite-alignment/LSN-0041-composer-must-own-public-sprite-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]}
|
||||
{"type":"discussion","id":"DSC-0028","status":"done","ticket":"studio-tiled-parser-assets-scene-asset-type","title":"Tiled Parser and Scene Asset-Type Ownership in Assets Workspace","created_at":"2026-04-15","updated_at":"2026-04-23","tags":["studio","assets","scene","tiled","parser","asset-type"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0045","file":"discussion/lessons/DSC-0028-studio-tiled-parser-assets-scene-asset-type/LSN-0045-asset-owned-scene-bank-and-tiled-xml-workflow.md","status":"done","created_at":"2026-04-23","updated_at":"2026-04-23"}]}
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -2,14 +2,10 @@ package p.studio;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import p.packer.Packer;
|
||||
import p.studio.lsp.LspServiceFactory;
|
||||
import p.studio.lsp.LspServiceFactoryImpl;
|
||||
import p.studio.lsp.events.StudioEventBus;
|
||||
import p.studio.lsp.events.StudioPackerEventAdapter;
|
||||
import p.studio.utilities.ThemeService;
|
||||
import p.studio.utilities.i18n.I18nService;
|
||||
import p.studio.vfs.FilesystemProjectDocumentVfsFactory;
|
||||
import p.studio.vfs.ProjectDocumentVfsFactory;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@ -21,8 +17,6 @@ public final class AppContainer implements Container {
|
||||
private final ThemeService themeService;
|
||||
private final StudioEventBus studioEventBus;
|
||||
private final ObjectMapper mapper;
|
||||
private final LspServiceFactory lspServiceFactory;
|
||||
private final ProjectDocumentVfsFactory projectDocumentVfsFactory;
|
||||
private final EmbeddedPacker embeddedPacker;
|
||||
private final StudioBackgroundTasks backgroundTasks;
|
||||
|
||||
@ -31,8 +25,6 @@ public final class AppContainer implements Container {
|
||||
this.themeService = new ThemeService();
|
||||
this.studioEventBus = new StudioEventBus();
|
||||
this.mapper = new ObjectMapper();
|
||||
this.lspServiceFactory = new LspServiceFactoryImpl();
|
||||
this.projectDocumentVfsFactory = new FilesystemProjectDocumentVfsFactory();
|
||||
final ExecutorService backgroundExecutor = Executors.newFixedThreadPool(2, new StudioWorkerThreadFactory());
|
||||
this.backgroundTasks = new StudioBackgroundTasks(backgroundExecutor);
|
||||
final Packer packer = Packer.bootstrap(this.mapper, new StudioPackerEventAdapter(studioEventBus));
|
||||
@ -59,16 +51,6 @@ public final class AppContainer implements Container {
|
||||
return mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspServiceFactory getPrometeuLspServiceFactory() {
|
||||
return lspServiceFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectDocumentVfsFactory getProjectDocumentVfsFactory() {
|
||||
return projectDocumentVfsFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EmbeddedPacker getPacker() {
|
||||
return embeddedPacker;
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
package p.studio;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import p.studio.lsp.LspServiceFactory;
|
||||
import p.studio.lsp.events.StudioEventBus;
|
||||
import p.studio.utilities.ThemeService;
|
||||
import p.studio.utilities.i18n.I18nService;
|
||||
import p.studio.vfs.ProjectDocumentVfsFactory;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@ -19,10 +17,6 @@ public interface Container {
|
||||
|
||||
ObjectMapper getMapper();
|
||||
|
||||
LspServiceFactory getPrometeuLspServiceFactory();
|
||||
|
||||
ProjectDocumentVfsFactory getProjectDocumentVfsFactory();
|
||||
|
||||
EmbeddedPacker getPacker();
|
||||
|
||||
StudioBackgroundTasks getBackgroundTasks();
|
||||
@ -66,14 +60,6 @@ public interface Container {
|
||||
return current().getMapper();
|
||||
}
|
||||
|
||||
static LspServiceFactory prometeuLspServiceFactory() {
|
||||
return current().getPrometeuLspServiceFactory();
|
||||
}
|
||||
|
||||
static ProjectDocumentVfsFactory projectDocumentVfsFactory() {
|
||||
return current().getProjectDocumentVfsFactory();
|
||||
}
|
||||
|
||||
static EmbeddedPacker packer() {
|
||||
return current().getPacker();
|
||||
}
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
package p.studio.projectsessions;
|
||||
|
||||
import p.studio.execution.StudioExecutionSessionService;
|
||||
import p.studio.lsp.LspService;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetup;
|
||||
import p.studio.projectstate.ProjectLocalStudioState;
|
||||
import p.studio.projectstate.ProjectLocalStudioStateService;
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.vfs.VfsProjectDocument;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class StudioProjectSession implements AutoCloseable {
|
||||
private final ProjectReference projectReference;
|
||||
private final LspService prometeuLspService;
|
||||
private final VfsProjectDocument vfsProjectDocument;
|
||||
private final ProjectLocalStudioStateService projectLocalStudioStateService;
|
||||
private final ProjectLocalStudioSetup projectLocalStudioSetup;
|
||||
private final StudioExecutionSessionService executionSessionService;
|
||||
@ -21,13 +17,9 @@ public final class StudioProjectSession implements AutoCloseable {
|
||||
private boolean closed;
|
||||
|
||||
public StudioProjectSession(
|
||||
final ProjectReference projectReference,
|
||||
final LspService prometeuLspService,
|
||||
final VfsProjectDocument vfsProjectDocument) {
|
||||
final ProjectReference projectReference) {
|
||||
this(
|
||||
projectReference,
|
||||
prometeuLspService,
|
||||
vfsProjectDocument,
|
||||
new ProjectLocalStudioStateService(),
|
||||
ProjectLocalStudioSetup.defaults(),
|
||||
new StudioExecutionSessionService(),
|
||||
@ -36,15 +28,11 @@ public final class StudioProjectSession implements AutoCloseable {
|
||||
|
||||
StudioProjectSession(
|
||||
final ProjectReference projectReference,
|
||||
final LspService prometeuLspService,
|
||||
final VfsProjectDocument vfsProjectDocument,
|
||||
final ProjectLocalStudioStateService projectLocalStudioStateService,
|
||||
final ProjectLocalStudioSetup projectLocalStudioSetup,
|
||||
final StudioExecutionSessionService executionSessionService,
|
||||
final ProjectLocalStudioState projectLocalStudioState) {
|
||||
this.projectReference = Objects.requireNonNull(projectReference, "projectReference");
|
||||
this.prometeuLspService = Objects.requireNonNull(prometeuLspService, "prometeuLspService");
|
||||
this.vfsProjectDocument = Objects.requireNonNull(vfsProjectDocument, "vfsProjectDocument");
|
||||
this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService");
|
||||
this.projectLocalStudioSetup = Objects.requireNonNull(projectLocalStudioSetup, "projectLocalStudioSetup");
|
||||
this.executionSessionService = Objects.requireNonNull(executionSessionService, "executionSessionService");
|
||||
@ -55,14 +43,6 @@ public final class StudioProjectSession implements AutoCloseable {
|
||||
return projectReference;
|
||||
}
|
||||
|
||||
public VfsProjectDocument projectDocumentVfs() {
|
||||
return vfsProjectDocument;
|
||||
}
|
||||
|
||||
public LspService prometeuLspService() {
|
||||
return prometeuLspService;
|
||||
}
|
||||
|
||||
public ProjectLocalStudioState projectLocalStudioState() {
|
||||
return projectLocalStudioState;
|
||||
}
|
||||
@ -96,25 +76,13 @@ public final class StudioProjectSession implements AutoCloseable {
|
||||
failure = runtimeException;
|
||||
}
|
||||
try {
|
||||
prometeuLspService.close();
|
||||
} catch (RuntimeException runtimeException) {
|
||||
if (failure == null) {
|
||||
failure = runtimeException;
|
||||
} else {
|
||||
failure.addSuppressed(runtimeException);
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
try {
|
||||
vfsProjectDocument.close();
|
||||
} catch (RuntimeException runtimeException) {
|
||||
if (failure == null) {
|
||||
failure = runtimeException;
|
||||
} else {
|
||||
failure.addSuppressed(runtimeException);
|
||||
} finally {
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,60 +1,36 @@
|
||||
package p.studio.projectsessions;
|
||||
|
||||
import p.studio.execution.StudioExecutionSessionService;
|
||||
import p.studio.lsp.messages.LspProjectContext;
|
||||
import p.studio.lsp.LspServiceFactory;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetupService;
|
||||
import p.studio.projectstate.ProjectLocalStudioStateService;
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.vfs.VfsProjectDocument;
|
||||
import p.studio.vfs.ProjectDocumentVfsFactory;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class StudioProjectSessionFactory {
|
||||
private final LspServiceFactory lspServiceFactory;
|
||||
private final ProjectDocumentVfsFactory projectDocumentVfsFactory;
|
||||
private final ProjectLocalStudioStateService projectLocalStudioStateService;
|
||||
private final ProjectLocalStudioSetupService projectLocalStudioSetupService;
|
||||
|
||||
public StudioProjectSessionFactory(
|
||||
final LspServiceFactory lspServiceFactory,
|
||||
final ProjectDocumentVfsFactory projectDocumentVfsFactory) {
|
||||
public StudioProjectSessionFactory() {
|
||||
this(
|
||||
lspServiceFactory,
|
||||
projectDocumentVfsFactory,
|
||||
new ProjectLocalStudioStateService(),
|
||||
new ProjectLocalStudioSetupService());
|
||||
}
|
||||
|
||||
StudioProjectSessionFactory(
|
||||
final LspServiceFactory lspServiceFactory,
|
||||
final ProjectDocumentVfsFactory projectDocumentVfsFactory,
|
||||
final ProjectLocalStudioStateService projectLocalStudioStateService,
|
||||
final ProjectLocalStudioSetupService projectLocalStudioSetupService) {
|
||||
this.lspServiceFactory = Objects.requireNonNull(lspServiceFactory, "prometeuLspServiceFactory");
|
||||
this.projectDocumentVfsFactory = Objects.requireNonNull(projectDocumentVfsFactory, "projectDocumentVfsFactory");
|
||||
this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService");
|
||||
this.projectLocalStudioSetupService = Objects.requireNonNull(projectLocalStudioSetupService, "projectLocalStudioSetupService");
|
||||
}
|
||||
|
||||
public StudioProjectSession open(final ProjectReference projectReference) {
|
||||
final ProjectReference target = Objects.requireNonNull(projectReference, "projectReference");
|
||||
final VfsProjectDocument vfsProjectDocument = projectDocumentVfsFactory.open(target.toVfsProjectContext());
|
||||
return new StudioProjectSession(
|
||||
target,
|
||||
lspServiceFactory.open(lspProjectContext(target), vfsProjectDocument),
|
||||
vfsProjectDocument,
|
||||
projectLocalStudioStateService,
|
||||
projectLocalStudioSetupService.load(target),
|
||||
new StudioExecutionSessionService(),
|
||||
projectLocalStudioStateService.load(target));
|
||||
}
|
||||
|
||||
private LspProjectContext lspProjectContext(final ProjectReference projectReference) {
|
||||
return new LspProjectContext(
|
||||
projectReference.name(),
|
||||
projectReference.languageId(),
|
||||
projectReference.rootPath());
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,57 +3,48 @@ package p.studio.projectstate;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ProjectLocalStudioState(
|
||||
int version,
|
||||
ShellLayout shellLayout,
|
||||
OpenShellState openShellState,
|
||||
EditorRestorationState editorRestoration) {
|
||||
OpenShellState openShellState) {
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
|
||||
public ProjectLocalStudioState {
|
||||
version = version <= 0 ? CURRENT_VERSION : version;
|
||||
shellLayout = shellLayout == null ? ShellLayout.defaults() : shellLayout.normalize();
|
||||
openShellState = openShellState == null ? OpenShellState.defaults() : openShellState.normalize();
|
||||
editorRestoration = editorRestoration == null ? EditorRestorationState.defaults() : editorRestoration.normalize();
|
||||
}
|
||||
|
||||
public static ProjectLocalStudioState defaults() {
|
||||
return new ProjectLocalStudioState(
|
||||
CURRENT_VERSION,
|
||||
ShellLayout.defaults(),
|
||||
OpenShellState.defaults(),
|
||||
EditorRestorationState.defaults());
|
||||
OpenShellState.defaults());
|
||||
}
|
||||
|
||||
public ProjectLocalStudioState withShellLayout(final ShellLayout nextShellLayout) {
|
||||
return new ProjectLocalStudioState(version, nextShellLayout, openShellState, editorRestoration);
|
||||
return new ProjectLocalStudioState(version, nextShellLayout, openShellState);
|
||||
}
|
||||
|
||||
public ProjectLocalStudioState withOpenShellState(final OpenShellState nextOpenShellState) {
|
||||
return new ProjectLocalStudioState(version, shellLayout, nextOpenShellState, editorRestoration);
|
||||
}
|
||||
|
||||
public ProjectLocalStudioState withEditorRestoration(final EditorRestorationState nextEditorRestoration) {
|
||||
return new ProjectLocalStudioState(version, shellLayout, openShellState, nextEditorRestoration);
|
||||
return new ProjectLocalStudioState(version, shellLayout, nextOpenShellState);
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ShellLayout(EditorLayoutState editorLayout, AssetsLayoutState assetsLayout) {
|
||||
public record ShellLayout(AssetsLayoutState assetsLayout) {
|
||||
public ShellLayout {
|
||||
editorLayout = editorLayout == null ? EditorLayoutState.defaults() : editorLayout.normalize();
|
||||
assetsLayout = assetsLayout == null ? AssetsLayoutState.defaults() : assetsLayout.normalize();
|
||||
}
|
||||
|
||||
public static ShellLayout defaults() {
|
||||
return new ShellLayout(EditorLayoutState.defaults(), AssetsLayoutState.defaults());
|
||||
return new ShellLayout(AssetsLayoutState.defaults());
|
||||
}
|
||||
|
||||
private ShellLayout normalize() {
|
||||
return new ShellLayout(editorLayout, assetsLayout);
|
||||
return new ShellLayout(assetsLayout);
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,6 +67,9 @@ public record ProjectLocalStudioState(
|
||||
public record OpenShellState(String selectedWorkspaceId) {
|
||||
public OpenShellState {
|
||||
selectedWorkspaceId = normalizeText(selectedWorkspaceId);
|
||||
if ("EDITOR".equals(selectedWorkspaceId)) {
|
||||
selectedWorkspaceId = "ASSETS";
|
||||
}
|
||||
}
|
||||
|
||||
public static OpenShellState defaults() {
|
||||
@ -87,80 +81,6 @@ public record ProjectLocalStudioState(
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record EditorLayoutState(
|
||||
List<Double> contentSplitDividers,
|
||||
List<Double> leftColumnDividers,
|
||||
List<Double> rightColumnDividers,
|
||||
DockPanelState outlinePanel,
|
||||
DockPanelState helperPanel,
|
||||
List<String> navigatorExpandedPaths) {
|
||||
public EditorLayoutState {
|
||||
contentSplitDividers = normalizeDividerPositions(contentSplitDividers);
|
||||
leftColumnDividers = normalizeDividerPositions(leftColumnDividers);
|
||||
rightColumnDividers = normalizeDividerPositions(rightColumnDividers);
|
||||
outlinePanel = outlinePanel == null ? DockPanelState.defaults() : outlinePanel.normalize();
|
||||
helperPanel = helperPanel == null ? DockPanelState.defaults() : helperPanel.normalize();
|
||||
navigatorExpandedPaths = normalizePaths(navigatorExpandedPaths);
|
||||
}
|
||||
|
||||
public static EditorLayoutState defaults() {
|
||||
return new EditorLayoutState(
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
DockPanelState.defaults(),
|
||||
DockPanelState.defaults(),
|
||||
List.of());
|
||||
}
|
||||
|
||||
private EditorLayoutState normalize() {
|
||||
return new EditorLayoutState(
|
||||
contentSplitDividers,
|
||||
leftColumnDividers,
|
||||
rightColumnDividers,
|
||||
outlinePanel,
|
||||
helperPanel,
|
||||
navigatorExpandedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record DockPanelState(boolean expanded, Double dividerPosition) {
|
||||
public DockPanelState {
|
||||
dividerPosition = normalizeDividerPosition(dividerPosition);
|
||||
}
|
||||
|
||||
public static DockPanelState defaults() {
|
||||
return new DockPanelState(true, null);
|
||||
}
|
||||
|
||||
private DockPanelState normalize() {
|
||||
return new DockPanelState(expanded, dividerPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record EditorRestorationState(List<String> openTabPaths, String activeTabPath) {
|
||||
public EditorRestorationState {
|
||||
openTabPaths = normalizePaths(openTabPaths);
|
||||
activeTabPath = normalizeText(activeTabPath);
|
||||
if (activeTabPath != null && !openTabPaths.contains(activeTabPath)) {
|
||||
final List<String> expanded = new ArrayList<>(openTabPaths);
|
||||
expanded.add(activeTabPath);
|
||||
openTabPaths = List.copyOf(expanded);
|
||||
}
|
||||
}
|
||||
|
||||
public static EditorRestorationState defaults() {
|
||||
return new EditorRestorationState(List.of(), null);
|
||||
}
|
||||
|
||||
private EditorRestorationState normalize() {
|
||||
return new EditorRestorationState(openTabPaths, activeTabPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeText(final String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
@ -169,20 +89,6 @@ public record ProjectLocalStudioState(
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private static List<String> normalizePaths(final List<String> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
final LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
for (final String value : values) {
|
||||
final String text = normalizeText(value);
|
||||
if (text != null) {
|
||||
normalized.add(text);
|
||||
}
|
||||
}
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private static List<Double> normalizeDividerPositions(final List<Double> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return List.of();
|
||||
@ -196,11 +102,4 @@ public record ProjectLocalStudioState(
|
||||
}
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private static Double normalizeDividerPosition(final Double value) {
|
||||
if (value == null || value.isNaN() || value.isInfinite()) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0.0d, Math.min(1.0d, value));
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,6 @@ import p.studio.workspaces.WorkspaceId;
|
||||
import p.studio.workspaces.assets.AssetWorkspace;
|
||||
import p.studio.workspaces.builder.ShipperWorkspace;
|
||||
import p.studio.workspaces.debug.DebugWorkspace;
|
||||
import p.studio.workspaces.editor.EditorWorkspace;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -24,7 +23,6 @@ public final class MainView extends BorderPane {
|
||||
private final ProjectReference projectReference;
|
||||
private final StudioProjectSession projectSession;
|
||||
private final AssetWorkspace assetWorkspace;
|
||||
private final EditorWorkspace editorWorkspace;
|
||||
private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail;
|
||||
private final StudioPlayStopCoordinator playStopCoordinator;
|
||||
private boolean initializing;
|
||||
@ -50,23 +48,14 @@ public final class MainView extends BorderPane {
|
||||
|
||||
assetWorkspace = new AssetWorkspace(projectReference);
|
||||
host.register(assetWorkspace);
|
||||
editorWorkspace = new EditorWorkspace(
|
||||
projectReference,
|
||||
projectSession.projectDocumentVfs(),
|
||||
projectSession.prometeuLspService(),
|
||||
projectSetup.editorIndentation());
|
||||
host.register(editorWorkspace);
|
||||
assetWorkspace.restoreProjectLocalState(persistedState);
|
||||
editorWorkspace.restoreProjectLocalState(persistedState);
|
||||
assetWorkspace.setStateChangedAction(this::persistProjectLocalState);
|
||||
editorWorkspace.setStateChangedAction(this::persistProjectLocalState);
|
||||
host.register(new DebugWorkspace(projectReference, projectSession.executionSession()));
|
||||
host.register(new ShipperWorkspace(projectReference));
|
||||
|
||||
workspaceRail = new StudioWorkspaceRailControl<>(
|
||||
List.of(
|
||||
new StudioWorkspaceRailItem<>(WorkspaceId.ASSETS, "📦", Container.i18n().bind(I18n.WORKSPACE_ASSETS)),
|
||||
new StudioWorkspaceRailItem<>(WorkspaceId.EDITOR, "📝", Container.i18n().bind(I18n.WORKSPACE_CODE)),
|
||||
new StudioWorkspaceRailItem<>(WorkspaceId.DEBUG, "🎮", Container.i18n().bind(I18n.WORKSPACE_DEBUG)),
|
||||
new StudioWorkspaceRailItem<>(WorkspaceId.SHIPPER, "⚙️", Container.i18n().bind(I18n.WORKSPACE_SHIPPER))
|
||||
),
|
||||
@ -102,14 +91,11 @@ public final class MainView extends BorderPane {
|
||||
projectSession.replaceProjectLocalStudioState(
|
||||
current.withOpenShellState(new ProjectLocalStudioState.OpenShellState(selectedWorkspace.name()))
|
||||
.withShellLayout(new ProjectLocalStudioState.ShellLayout(
|
||||
editorWorkspace.captureLayoutState(),
|
||||
assetWorkspace.captureLayoutState()))
|
||||
.withEditorRestoration(editorWorkspace.captureRestorationState()));
|
||||
assetWorkspace.captureLayoutState())));
|
||||
}
|
||||
|
||||
public void reapplyProjectLocalLayout() {
|
||||
assetWorkspace.reapplyPendingLayoutState();
|
||||
editorWorkspace.reapplyPendingLayoutState();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
|
||||
@ -37,9 +37,7 @@ public final class StudioWindowCoordinator {
|
||||
this.projectCatalogService = new ProjectCatalogService(resolveDefaultProjectsRoot());
|
||||
this.knownProjectsService = new KnownProjectsService(resolveKnownProjectsStorage(), projectCatalogService);
|
||||
this.windowStateService = new WindowStateService(resolveWindowStateStorage());
|
||||
this.projectSessionFactory = new StudioProjectSessionFactory(
|
||||
Container.prometeuLspServiceFactory(),
|
||||
Container.projectDocumentVfsFactory());
|
||||
this.projectSessionFactory = new StudioProjectSessionFactory();
|
||||
this.launcherView = new ProjectLauncherView(
|
||||
knownProjectsService,
|
||||
projectCatalogService,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package p.studio.workspaces;
|
||||
|
||||
public enum WorkspaceId {
|
||||
EDITOR,
|
||||
ASSETS,
|
||||
SHIPPER,
|
||||
DEBUG
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
package p.studio.workspaces.editor;
|
||||
|
||||
enum EditorDocumentHighlightOwner {
|
||||
LOCAL,
|
||||
LSP
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
@ -1,25 +1,11 @@
|
||||
package p.studio.projectsessions;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import p.studio.lsp.messages.LspProjectContext;
|
||||
import p.studio.lsp.LspService;
|
||||
import p.studio.lsp.LspServiceFactory;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetup;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetupService;
|
||||
import p.studio.projectstate.ProjectLocalStudioState;
|
||||
import p.studio.projectstate.ProjectLocalStudioStateService;
|
||||
import p.studio.lsp.dtos.LspSessionStateDTO;
|
||||
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
|
||||
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
|
||||
import p.studio.lsp.messages.LspDefinitionRequest;
|
||||
import p.studio.lsp.messages.LspDefinitionResult;
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.vfs.VfsProjectDocument;
|
||||
import p.studio.vfs.ProjectDocumentVfsFactory;
|
||||
import p.studio.vfs.VfsDocumentOpenResult;
|
||||
import p.studio.vfs.messages.VfsProjectContext;
|
||||
import p.studio.vfs.messages.VfsProjectSnapshot;
|
||||
import p.studio.vfs.messages.VfsRefreshRequest;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@ -28,14 +14,10 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
final class StudioProjectSessionFactoryTest {
|
||||
@Test
|
||||
void openCreatesProjectSessionBackedByProjectScopedVfs() {
|
||||
final RecordingLspFactory lspFactory = new RecordingLspFactory();
|
||||
final RecordingVfsFactory vfsFactory = new RecordingVfsFactory();
|
||||
void openCreatesProjectSessionWithoutLegacyEditorServices() {
|
||||
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
|
||||
final RecordingProjectLocalStudioSetupService setupService = new RecordingProjectLocalStudioSetupService();
|
||||
final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory(
|
||||
lspFactory,
|
||||
vfsFactory,
|
||||
stateService,
|
||||
setupService);
|
||||
final ProjectReference projectReference = new ProjectReference(
|
||||
@ -48,14 +30,6 @@ final class StudioProjectSessionFactoryTest {
|
||||
final StudioProjectSession session = sessionFactory.open(projectReference);
|
||||
|
||||
assertSame(projectReference, session.projectReference());
|
||||
assertSame(vfsFactory.vfs, session.projectDocumentVfs());
|
||||
assertSame(lspFactory.service, session.prometeuLspService());
|
||||
assertEquals("Example", vfsFactory.capturedContext.projectName());
|
||||
assertEquals("pbs", vfsFactory.capturedContext.languageId());
|
||||
assertEquals(projectReference.rootPath().toAbsolutePath().normalize(), vfsFactory.capturedContext.rootPath());
|
||||
assertEquals("Example", lspFactory.capturedContext.projectName());
|
||||
assertEquals("pbs", lspFactory.capturedContext.languageId());
|
||||
assertSame(vfsFactory.vfs, lspFactory.capturedVfs);
|
||||
assertSame(stateService.loadedState, session.projectLocalStudioState());
|
||||
assertSame(setupService.loadedSetup, session.projectLocalStudioSetup());
|
||||
assertEquals("IDLE", session.executionSession().snapshot().state().name());
|
||||
@ -68,8 +42,7 @@ final class StudioProjectSessionFactoryTest {
|
||||
private final ProjectLocalStudioState loadedState = new ProjectLocalStudioState(
|
||||
ProjectLocalStudioState.CURRENT_VERSION,
|
||||
ProjectLocalStudioState.ShellLayout.defaults(),
|
||||
new ProjectLocalStudioState.OpenShellState("EDITOR"),
|
||||
ProjectLocalStudioState.EditorRestorationState.defaults());
|
||||
new ProjectLocalStudioState.OpenShellState("ASSETS"));
|
||||
|
||||
@Override
|
||||
public ProjectLocalStudioState load(final ProjectReference projectReference) {
|
||||
@ -90,84 +63,4 @@ final class StudioProjectSessionFactoryTest {
|
||||
return loadedSetup;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingVfsFactory implements ProjectDocumentVfsFactory {
|
||||
private VfsProjectContext capturedContext;
|
||||
private final VfsProjectDocument vfs = new NoOpVfsProjectDocument();
|
||||
|
||||
@Override
|
||||
public VfsProjectDocument open(VfsProjectContext projectContext) {
|
||||
this.capturedContext = projectContext;
|
||||
return vfs;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingLspFactory implements LspServiceFactory {
|
||||
private LspProjectContext capturedContext;
|
||||
private VfsProjectDocument capturedVfs;
|
||||
private final LspService service = new NoOpPrometeuLspService();
|
||||
|
||||
@Override
|
||||
public LspService open(
|
||||
final LspProjectContext projectContext,
|
||||
final VfsProjectDocument vfsProjectDocument) {
|
||||
this.capturedContext = projectContext;
|
||||
this.capturedVfs = vfsProjectDocument;
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoOpVfsProjectDocument implements VfsProjectDocument {
|
||||
@Override
|
||||
public VfsProjectContext projectContext() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectSnapshot snapshot() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectSnapshot refresh() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectSnapshot refresh(VfsRefreshRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsDocumentOpenResult openDocument(Path path) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoOpPrometeuLspService implements LspService {
|
||||
@Override
|
||||
public LspProjectContext projectContext() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectDocument projectDocumentVfs() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspSessionStateDTO snapshot() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspAnalyzeDocumentResult analyzeDocument(final LspAnalyzeDocumentRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspDefinitionResult definition(final LspDefinitionRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,22 +2,10 @@ package p.studio.projectsessions;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import p.studio.execution.StudioExecutionSessionService;
|
||||
import p.studio.lsp.messages.LspProjectContext;
|
||||
import p.studio.lsp.LspService;
|
||||
import p.studio.projectstate.ProjectLocalStudioSetup;
|
||||
import p.studio.projectstate.ProjectLocalStudioState;
|
||||
import p.studio.projectstate.ProjectLocalStudioStateService;
|
||||
import p.studio.lsp.dtos.LspSessionStateDTO;
|
||||
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
|
||||
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
|
||||
import p.studio.lsp.messages.LspDefinitionRequest;
|
||||
import p.studio.lsp.messages.LspDefinitionResult;
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.vfs.VfsProjectDocument;
|
||||
import p.studio.vfs.VfsDocumentOpenResult;
|
||||
import p.studio.vfs.messages.VfsProjectContext;
|
||||
import p.studio.vfs.messages.VfsProjectSnapshot;
|
||||
import p.studio.vfs.messages.VfsRefreshRequest;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@ -26,14 +14,10 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
final class StudioProjectSessionTest {
|
||||
@Test
|
||||
void closeDelegatesToUnderlyingServicesOnlyOnce() {
|
||||
final CountingVfsProjectDocument vfs = new CountingVfsProjectDocument();
|
||||
final CountingPrometeuLspService lsp = new CountingPrometeuLspService(vfs);
|
||||
void closeSavesStateOnlyOnce() {
|
||||
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
|
||||
final StudioProjectSession session = new StudioProjectSession(
|
||||
projectReference(),
|
||||
lsp,
|
||||
vfs,
|
||||
stateService,
|
||||
ProjectLocalStudioSetup.defaults(),
|
||||
new StudioExecutionSessionService(),
|
||||
@ -42,27 +26,21 @@ final class StudioProjectSessionTest {
|
||||
session.close();
|
||||
session.close();
|
||||
|
||||
assertEquals(1, lsp.closeCalls);
|
||||
assertEquals(1, vfs.closeCalls);
|
||||
assertEquals(1, stateService.saveCalls);
|
||||
assertEquals(projectReference(), stateService.savedProjectReference);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceProjectLocalStudioStateUpdatesTheSavedSnapshot() {
|
||||
final CountingVfsProjectDocument vfs = new CountingVfsProjectDocument();
|
||||
final CountingPrometeuLspService lsp = new CountingPrometeuLspService(vfs);
|
||||
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
|
||||
final StudioProjectSession session = new StudioProjectSession(
|
||||
projectReference(),
|
||||
lsp,
|
||||
vfs,
|
||||
stateService,
|
||||
ProjectLocalStudioSetup.defaults(),
|
||||
new StudioExecutionSessionService(),
|
||||
ProjectLocalStudioState.defaults());
|
||||
final ProjectLocalStudioState nextState = ProjectLocalStudioState.defaults()
|
||||
.withOpenShellState(new ProjectLocalStudioState.OpenShellState("EDITOR"));
|
||||
.withOpenShellState(new ProjectLocalStudioState.OpenShellState("ASSETS"));
|
||||
|
||||
session.replaceProjectLocalStudioState(nextState);
|
||||
session.close();
|
||||
@ -86,77 +64,4 @@ final class StudioProjectSessionTest {
|
||||
savedState = state;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CountingVfsProjectDocument implements VfsProjectDocument {
|
||||
private int closeCalls;
|
||||
|
||||
@Override
|
||||
public VfsProjectContext projectContext() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectSnapshot snapshot() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectSnapshot refresh() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectSnapshot refresh(VfsRefreshRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsDocumentOpenResult openDocument(Path path) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closeCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CountingPrometeuLspService implements LspService {
|
||||
private final VfsProjectDocument vfsProjectDocument;
|
||||
private int closeCalls;
|
||||
|
||||
private CountingPrometeuLspService(final VfsProjectDocument vfsProjectDocument) {
|
||||
this.vfsProjectDocument = vfsProjectDocument;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspProjectContext projectContext() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VfsProjectDocument projectDocumentVfs() {
|
||||
return vfsProjectDocument;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspSessionStateDTO snapshot() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspAnalyzeDocumentResult analyzeDocument(final LspAnalyzeDocumentRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LspDefinitionResult definition(final LspDefinitionRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closeCalls++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,22 +23,8 @@ final class ProjectLocalStudioStateServiceTest {
|
||||
final ProjectLocalStudioState state = new ProjectLocalStudioState(
|
||||
ProjectLocalStudioState.CURRENT_VERSION,
|
||||
new ProjectLocalStudioState.ShellLayout(
|
||||
new ProjectLocalStudioState.EditorLayoutState(
|
||||
List.of(0.25d),
|
||||
List.of(0.80d),
|
||||
List.of(0.70d),
|
||||
new ProjectLocalStudioState.DockPanelState(true, 0.80d),
|
||||
new ProjectLocalStudioState.DockPanelState(false, 0.70d),
|
||||
List.of(
|
||||
project.rootPath().resolve("src").toString(),
|
||||
project.rootPath().resolve("src/game").toString())),
|
||||
new ProjectLocalStudioState.AssetsLayoutState(List.of(0.42d))),
|
||||
new ProjectLocalStudioState.OpenShellState("EDITOR"),
|
||||
new ProjectLocalStudioState.EditorRestorationState(
|
||||
List.of(
|
||||
project.rootPath().resolve("src/main.pbs").toString(),
|
||||
project.rootPath().resolve("README.md").toString()),
|
||||
project.rootPath().resolve("src/main.pbs").toString()));
|
||||
new ProjectLocalStudioState.OpenShellState("ASSETS"));
|
||||
|
||||
service.save(project, state);
|
||||
|
||||
@ -72,6 +58,43 @@ final class ProjectLocalStudioStateServiceTest {
|
||||
assertEquals(ProjectLocalStudioState.defaults(), service.load(project));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyEditorStateLoadsWithAssetsFallbackAndIgnoresEditorFields() throws Exception {
|
||||
final ProjectLocalStudioStateService service = new ProjectLocalStudioStateService();
|
||||
final ProjectReference project = project("main");
|
||||
Files.createDirectories(ProjectStudioPaths.studioRoot(project));
|
||||
Files.writeString(
|
||||
ProjectStudioPaths.statePath(project),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"shellLayout": {
|
||||
"editorLayout": {
|
||||
"contentSplitDividers": [0.2, 0.8]
|
||||
},
|
||||
"assetsLayout": {
|
||||
"contentSplitDividers": [0.42]
|
||||
}
|
||||
},
|
||||
"openShellState": {
|
||||
"selectedWorkspaceId": "EDITOR"
|
||||
},
|
||||
"editorRestoration": {
|
||||
"openTabPaths": ["src/main.pbs"],
|
||||
"activeTabPath": "src/main.pbs"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertEquals(
|
||||
new ProjectLocalStudioState(
|
||||
ProjectLocalStudioState.CURRENT_VERSION,
|
||||
new ProjectLocalStudioState.ShellLayout(
|
||||
new ProjectLocalStudioState.AssetsLayoutState(List.of(0.42d))),
|
||||
new ProjectLocalStudioState.OpenShellState("ASSETS")),
|
||||
service.load(project));
|
||||
}
|
||||
|
||||
private ProjectReference project(final String name) {
|
||||
final Path projectRoot = tempDir.resolve(name);
|
||||
return new ProjectReference("Main", "1.0.0", "pbs", 1, projectRoot);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user