studio session persistent

This commit is contained in:
bQUARKz 2026-04-04 06:57:33 +01:00
parent d1722b23a3
commit a824b2185f
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
24 changed files with 1612 additions and 26 deletions

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":18,"AGD":19,"DEC":16,"PLN":37,"LSN":31,"CLSN":1}}
{"type":"meta","next_id":{"DSC":19,"AGD":20,"DEC":17,"PLN":38,"LSN":31,"CLSN":1}}
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"studio-docs-import","title":"Import docs/studio into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0001-assets-workspace-execution-wave-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0002-bank-composition-editor-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0003-mental-model-asset-mutations-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0004-mental-model-assets-workspace-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0005-mental-model-studio-events-and-components-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0006-mental-model-studio-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0007-pack-wizard-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0008-project-scoped-state-and-activity-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0016-studio-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]}
{"type":"discussion","id":"DSC-0002","status":"open","ticket":"palette-management-in-studio","title":"Palette Management in Studio","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","legacy-import","palette-management","tile-bank","packer-boundary"],"agendas":[{"id":"AGD-0002","file":"AGD-0002-palette-management-in-studio.md","status":"open","created_at":"2026-03-26","updated_at":"2026-03-26"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0003","status":"done","ticket":"packer-docs-import","title":"Import docs/packer into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["packer","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0009","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0009-mental-model-packer-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0010","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0010-asset-identity-and-runtime-contract-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0011","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0011-foundations-workspace-runtime-and-build-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0012","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0012-runtime-ownership-and-studio-boundary-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0013","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0013-metadata-convergence-and-runtime-sink-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0014","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0014-pack-wizard-summary-validation-and-pack-execution-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0015","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0015-tile-bank-packing-contract-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0017","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0017-packer-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]}
@ -16,3 +16,4 @@
{"type":"discussion","id":"DSC-0015","status":"done","ticket":"pbs-service-facade-reserved-metadata","title":"SDK Service Bodies Calling Builtin/Intrinsic Proxies as Ordinary PBS Code","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["compiler","pbs","sdk","stdlib","lowering","service","intrinsic","sdk-interface"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"discussion/lessons/DSC-0015-pbs-service-facade-reserved-metadata/LSN-0030-sdk-service-bodies-over-private-reserved-proxies.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"}]}
{"type":"discussion","id":"DSC-0016","status":"open","ticket":"studio-editor-scope-guides-and-brace-anchoring","title":"Scope Guides do Code Editor com ancoragem exata em braces e destaque do escopo ativo","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","scope-guides","braces","semantic-read","frontend-contract"],"agendas":[{"id":"AGD-0017","file":"AGD-0017-studio-editor-scope-guides-and-brace-anchoring.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0014","file":"DEC-0014-studio-editor-active-scope-and-structural-anchors.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"plans":[{"id":"PLN-0030","file":"PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0031","file":"PLN-0031-studio-structural-anchor-semantic-surface-specification.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0032","file":"PLN-0032-frontend-structural-anchor-payloads-and-anchor-aware-tests.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"}],"lessons":[]}
{"type":"discussion","id":"DSC-0017","status":"open","ticket":"studio-editor-inline-type-hints-for-let-bindings","title":"Inline Type Hints for Let Bindings in the Studio Editor","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","inline-hints","inlay-hints","lsp","pbs","type-inference"],"agendas":[{"id":"AGD-0018","file":"AGD-0018-studio-editor-inline-type-hints-for-let-bindings.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0015","file":"DEC-0015-studio-editor-inline-type-hints-contract-and-rendering-model.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03","ref_agenda":"AGD-0018"}],"plans":[{"id":"PLN-0033","file":"PLN-0033-inline-hint-spec-and-contract-propagation.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0034","file":"PLN-0034-lsp-inline-hint-transport-contract.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0035","file":"PLN-0035-pbs-inline-type-hint-payload-production.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]},{"id":"PLN-0036","file":"PLN-0036-studio-inline-hint-rendering-and-rollout.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03","ref_decisions":["DEC-0015"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0018","status":"open","ticket":"studio-project-local-studio-state-under-dot-studio","title":"Persist project-local Studio state under .studio","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","project-session","project-state","persistence","dot-studio","shell","layout","setup"],"agendas":[{"id":"AGD-0019","file":"AGD-0019-studio-project-open-state-under-dot-studio.md","status":"accepted","created_at":"2026-04-04","updated_at":"2026-04-04"}],"decisions":[{"id":"DEC-0016","file":"DEC-0016-project-local-studio-state-under-dot-studio.md","status":"accepted","created_at":"2026-04-04","updated_at":"2026-04-04","ref_agenda":"AGD-0019"}],"plans":[{"id":"PLN-0037","file":"PLN-0037-project-local-studio-state-store-and-restoration.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04","ref_decisions":["DEC-0016"]}],"lessons":[]}

View File

@ -0,0 +1,213 @@
---
id: AGD-0019
ticket: studio-project-local-studio-state-under-dot-studio
title: Persist project-local Studio state under .studio
status: accepted
created: 2026-04-04
resolved: 2026-04-04
decision: DEC-0016
tags: [studio, project-session, project-state, persistence, dot-studio, shell, layout, setup]
---
## Pain
O Studio ja tem um modelo de `project session` para runtime do projeto aberto, mas ainda nao tem um contrato explicito para estado persistente do proprio Studio por projeto.
Sem esse contrato, a organizacao e o setup do Studio por projeto correm tres riscos:
- virar estado apenas em memoria e se perder toda vez que o Studio fecha;
- ser espalhado em arquivos ad hoc, sem uma raiz reservada e sem schema claro;
- misturar configuracao local do projeto com estado global do launcher, quebrando o boundary entre "este projeto no Studio" e "preferencias gerais da aplicacao".
## Context
Domain owner: `studio`
Owner surfaces: `docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md`
Related context: `discussion/lessons/DSC-0001-studio-docs-import/LSN-0008-project-scoped-state-and-activity-legacy.md`, `discussion/lessons/DSC-0012-studio-editor-document-vfs-boundary/LSN-0027-project-document-vfs-and-session-owned-editor-boundary.md`
Relevant code: `prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java`
Ja existe precedente documental para duas direcoes importantes:
- `.studio/` e a raiz reservada para estado local ao projeto;
- estado que precisa sobreviver a troca de workspace, mas continuar pertencendo ao projeto aberto, deve ser owned por `project session`.
O que ainda nao esta fechado e o contrato desta nova camada de estado persistente do Studio por projeto:
- quais dados entram nela;
- quais dados devem continuar apenas em memoria;
- o que e projeto-local versus launcher-global;
- como versionar, carregar, gravar e tolerar corrupcao parcial;
- se o modelo deve ser um store unico do projeto ou varios arquivos especializados sob `.studio/`.
Exemplos concretos do escopo pretendido agora:
- posicoes de divisorias e proporcoes de layout;
- quais janelas, paineis ou workspaces estavam abertos;
- quais tabs do editor estavam abertas e qual estava ativa;
- caminhos de setup local do Studio para o projeto, como runtime do Prometeu;
- futuras configuracoes locais do Studio que pertencam a este projeto e nao ao launcher global.
Categoria relacionada, mas separada deste contrato principal:
- `project-local activity` pode continuar vivendo sob `.studio/`, mas nao precisa pertencer ao mesmo store canonico de `project session` usado para layout, tabs, janelas e setup.
Observacao importante:
- `project-local setup` ja e requisito relevante agora, mesmo antes de existir uma UI dedicada para edita-lo, porque o Studio pode precisar consumir configuracoes locais do projeto antes da superficie visual de configuracao ficar pronta.
## Open Questions
### What to store
- [ ] Qual deve ser o escopo minimo obrigatorio do estado persistido do Studio por projeto na primeira wave?
- [ ] O owner normativo do runtime deve ser `StudioProjectSession` com persistencia sob `.studio/`, ou cada workspace pode persistir diretamente seu proprio estado?
- [x] O contrato deve usar um arquivo consolidado de estado do projeto ou uma arvore de arquivos especializados dentro de `.studio/`?
Fechado: `single file`. O store principal da `project session` deve usar um arquivo consolidado, por ser mais simples de manter e governar.
- [ ] Quais categorias entram desde a wave 1: layout do shell, divisorias, paineis e janelas abertas, tabs abertas, documento ativo, caminhos de setup local, configuracoes por workspace?
- [ ] `Project-local setup` deve ser tratado como categoria de primeira classe desde o inicio, mesmo antes de haver UI propria?
- [x] A `project-local activity` deve pertencer ao mesmo store de `project session` desta agenda?
Fechado: nao. `Project-local activity` pode viver dentro de `.studio/`, mas deve permanecer dissociada do store principal de `project session` tratado nesta agenda.
- [ ] Quais categorias devem ser explicitamente proibidas por agora: cache derivado, estado efemero de hover, resultados recomputaveis, output operacional pesado, artefatos operacionais transient?
### When to save and restore
- [x] Quando o Studio grava esse estado: sob eventos incrementais, no fechamento da sessao, em pontos de checkpoint, ou combinacao controlada?
Fechado: a politica base deve ser gravacao no fechamento; checkpoints sao aceitaveis apenas para categorias que realmente precisarem disso; gravacao incremental como regra geral nao e a direcao.
- [x] Quais categorias devem ser restauradas imediatamente ao abrir o projeto e quais devem ser carregadas sob demanda?
Fechado: o estado restauravel de shell deve ser reaplicado quando o projeto sobe; no mesmo boundary, quando o shell do projeto desce, o estado correspondente deve ser salvo.
- [x] O setup local do projeto deve ser lido logo na abertura da `project session`, antes mesmo de o usuario abrir workspaces especificos?
Fechado: sim. O setup local do projeto deve ser lido na abertura da `project session`.
- [x] Como o Studio deve reagir a `.studio/` ausente, estado em schema antigo, arquivo invalido ou dados parcialmente corrompidos?
Fechado: fallback para valores default. A ausencia de `.studio/`, schema nao reconhecido ou conteudo invalido/corrompido nao deve impedir a abertura do projeto; o Studio deve cair para defaults seguros.
## Options
### Option A - Persistencia ad hoc por area do Studio sob `.studio/`
- **Approach:** cada area do Studio grava diretamente os arquivos de que precisa sob `.studio/`, por exemplo shell/layout, editor tabs, setup local e outros modulos futuros, sem um owner central de sessao nem um schema compartilhado forte.
- **Pro:** destrava persistencia rapido para features isoladas e exige pouco desenho inicial.
- **Con:** tende a fragmentar naming, lifecycle, migration e recovery; piora consistencia entre workspaces e dificulta distinguir estado normativo de lixo historico.
- **Maintainability:** baixa a media. Facil de iniciar, dificil de governar quando mais workspaces passarem a persistir.
- **Trade-offs detalhados:**
- reduz custo de entrada para a primeira feature consumidora, mas troca esse ganho por uma futura consolidacao mais cara;
- favorece autonomia local de shell e workspaces, mas quebra o boundary de `project session` que o repositorio vem fortalecendo;
- simplifica a escrita inicial, mas complica leitura, migracao de schema e tolerancia a falhas, porque cada workspace pode passar a degradar de um jeito diferente;
- permite fatiar rollout por workspace, mas aumenta o risco de formatos incompatíveis coexistirem dentro de `.studio/`.
### Option B - Project-session state store canonico sob `.studio/`
- **Approach:** definir um contrato explicito de estado persistente do Studio por projeto, owned pela `StudioProjectSession`, com raiz canonica em `.studio/` e schema/versionamento governados pelo Studio.
- **Pro:** preserva o boundary correto entre runtime de sessao, persistencia local do projeto e concerns globais do launcher; facilita recovery, migration e extensao futura.
- **Con:** exige desenhar ownership, schema e politica de gravacao antes de implementar a primeira feature consumidora.
- **Maintainability:** forte. O Studio ganha uma surface clara para persistencia de layout, tabs, setup local e futuras configuracoes por projeto sem espalhar responsabilidade por cada modulo.
- **Trade-offs detalhados:**
- aumenta custo de desenho agora, mas reduz custo estrutural das proximas features que precisarem persistir estado por projeto;
- centraliza ownership e recovery, o que melhora consistencia, mas exige uma API suficientemente clara para nao virar gargalo ou objeto “god state”;
- facilita schema versioning, migration e corrupcao parcial, mas pede disciplina para separar configuracao local canonica de cache derivado;
- protege a diferenca entre projeto-local e launcher-global, mas exige que o recorte inicial seja pequeno o bastante para nao bloquear implementacao por excesso de ambicao;
- acomoda bem layout, setup e estado restauravel de UI, mas precisa de limites claros para nao começar a absorver qualquer detalhe efemero de sessao.
### Option C - Manter apenas estado global do launcher e evitar persistencia por projeto nesta fase
- **Approach:** limitar a memoria persistente a recent projects e preferencias globais; cada projeto reconstroi layout, setup local e tabs do zero a cada abertura.
- **Pro:** menor custo imediato e nenhum contrato novo dentro do projeto.
- **Con:** conflita com o objetivo de tornar o projeto aberto uma unidade com estado proprio; degrada continuidade de uso e ignora o precedente ja registrado para `.studio/`.
- **Maintainability:** simples no curtissimo prazo, mas funcionalmente insuficiente se o Studio quer evoluir como ambiente de trabalho persistente por projeto.
- **Trade-offs detalhados:**
- preserva simplicidade operacional agora, mas empurra o problema para depois sem melhorar a arquitetura do projeto aberto;
- evita erro de modelagem prematuro, mas tambem impede validar cedo a disciplina de `.studio/` e `project session`;
- mantem o launcher como unico ponto persistente, mas mistura setup local do projeto com concerns globais que deveriam permanecer fora do projeto;
- reduz risco de schema local mal desenhado, mas aumenta risco de cada nova feature tentar contornar a ausencia de persistencia por atalhos locais.
## Discussion
O ponto principal aqui nao e apenas "onde salvar um JSON". O que precisa ser fechado e o boundary arquitetural:
1. ownership de runtime:
quem monta o estado em memoria enquanto o projeto esta aberto;
2. ownership de persistencia:
quem serializa e restaura esse estado sem deixar cada workspace inventar sua propria disciplina;
3. shape do dado:
o que conta como organizacao e setup do Studio por projeto, o que e apenas view state efemero e o que pertence ao launcher global;
4. resiliencia:
como o Studio lida com schema evolution, arquivos ausentes e degradacao parcial sem falhar ao abrir um projeto.
O precedente atual aponta para uma direcao relativamente forte:
- `.studio/` deve continuar sendo a raiz reservada do projeto para concerns do Studio;
- `StudioProjectSession` ja e o owner natural do estado que vive acima do foco de um workspace;
- workspaces devem consumir servicos de sessao, nao redefinir ownership de persistencia por conta propria.
- nem todo dado sob `.studio/` precisa cair dentro do mesmo store de `project session`; `activity` e o exemplo claro de uma categoria projeto-local, mas com semantica e politica de persistencia diferentes.
- para o store principal desta agenda, `single file` e a direcao preferida, porque simplifica manutencao, leitura, escrita e governanca inicial.
O risco de ir por um caminho ad hoc e que shell, editor e setup local comecem a salvar layout, tabs e caminhos em formatos independentes e acidentalmente normativos.
Depois disso, consolidar ownership, migration e cleanup fica mais caro do que desenhar a superficie certa agora.
Ao mesmo tempo, uma primeira wave disciplinada nao precisa resolver toda a persistencia futura do Studio.
Ela pode fechar:
- a raiz canonicamente reservada;
- o owner do runtime e da serializacao;
- as categorias de estado permitidas e proibidas na wave inicial;
- o formato geral de schema e versionamento;
- a politica de recovery minima para nao travar abertura de projeto.
Trade-off central da agenda:
- se priorizarmos velocidade local da primeira feature, Option A parece atraente, mas ela compra essa velocidade quebrando o owner correto;
- se priorizarmos architecture hygiene, Option B e a direcao mais consistente, mas ela precisa de um recorte rigoroso para nao tentar resolver persistencia total do Studio numa tacada so;
- Option C so faz sentido se o objetivo real nao for ainda ter projeto aberto como unidade persistente, o que conflita com a premissa desta agenda.
Os trade-offs tambem se separam naturalmente em dois eixos:
1. `what to store`
quanto mais categorias entram na wave 1, maior o valor imediato de restauracao do Studio por projeto;
por outro lado, maior o risco de misturar setup canonico com estado efemero ou cache derivado.
A convergencia atual deste eixo ja sugere uma separacao interna:
- um store principal de `project session` para layout, setup, janelas e restauracao de tabs;
- stores ou artefatos adjacentes sob `.studio/` para categorias com semantica diferente, como `project-local activity`.
2. `when to save/restore`
quanto mais automatico e frequente for o save, menor a perda de estado;
por outro lado, maior o risco de churn de escrita, snapshots inconsistentes e acoplamento excessivo a eventos de UI.
Leituras ja convergidas neste eixo:
- `project-local setup` deve ser restaurado logo na abertura da `project session`;
- o estado restauravel do shell deve subir com o projeto e ser salvo quando esse shell descer;
- `save on close` deve ser a politica padrao;
- checkpoints podem existir, mas apenas quando alguma categoria realmente exigir persistencia intermediaria.
Leitura atual dos trade-offs:
1. o maior risco de produto e nao ter continuidade de organizacao e setup do Studio entre aberturas do projeto;
2. `project-local setup` tem prioridade alta porque pode ser consumido mesmo antes da UI de configuracao existir;
3. o maior risco arquitetural e permitir que shell e workspaces inventem persistencia independente para layout, tabs e configuracao local;
4. o maior risco de execucao em Option B e superdimensionar a primeira wave ou salvar estado demais cedo demais;
5. portanto, a melhor convergencia parece ser um store canonico de sessao/projeto com escopo inicial deliberadamente estreito, `single file` no inicio, mas explicitamente capaz de crescer para layout, setup e configuracoes futuras.
## Resolution
Recommended direction: seguir com **Option B**.
A agenda deve convergir para uma decision com os seguintes fechamentos:
1. `.studio/` permanece a raiz canonica de estado local ao projeto para o Studio;
2. `StudioProjectSession` ou um servico explicitamente owned por ela deve ser o owner do runtime e da persistencia do estado local do Studio por projeto;
3. shell e workspaces devem consumir esse estado por contrato, em vez de cada um persistir arbitrariamente por conta propria;
4. a primeira wave deve definir claramente quais categorias entram, com forte prioridade para `project-local setup`, layout restauravel e tabs/janelas abertas;
5. o contrato deve prever schema/versionamento e recovery de dados ausentes ou invalidos;
6. o boundary entre estado projeto-local e estado launcher-global deve ficar explicito;
7. o desenho inicial deve suportar extensao futura para novas categorias de configuracao local sem exigir reorganizacao ad hoc de `.studio/`;
8. a decision deve separar explicitamente `what to store` de `when to save/restore`, em vez de misturar conteudo e politica operacional;
9. a politica base de persistencia deve ser `read on project-session open`, `restore on shell mount`, `save on shell/project close`, com checkpoints apenas quando justificadamente necessarios;
10. `project-local activity` deve ser explicitamente tratada como concern separado: continua sob `.studio/`, mas fora do store principal de `project session`;
11. o store principal deve comecar como `single file`;
12. ausencia, schema antigo ou conteudo invalido/corrompido devem degradar para valores default seguros, sem bloquear abertura do projeto.
Next step suggestion: fechar nesta agenda, em separado, `what to store` e `when to save/restore`:
- `what to store`: pelo menos `project-local setup`, `shell layout`, `open panels/workspaces`, `editor open tabs + active tab`;
- `when to save/restore`: leitura do setup ja na abertura da `project session`, restauracao do layout/tabs na entrada do shell, save no fechamento, e checkpoints apenas para categorias que realmente precisarem.

View File

@ -0,0 +1,169 @@
---
id: DEC-0016
ticket: studio-project-local-studio-state-under-dot-studio
title: Project-Local Studio State under .studio
status: accepted
created: 2026-04-04
accepted: 2026-04-04
agenda: AGD-0019
plans: []
tags: [studio, project-session, project-state, persistence, dot-studio, shell, layout, setup]
---
## Decision
The Studio MUST persist project-local Studio state under `.studio/` in the project root.
This decision normatively separates three concerns:
1. project-local Studio state owned by the project and restored with that project;
2. launcher-global state that is not owned by any specific project;
3. project-local activity artifacts that may live under `.studio/` but are not part of the main project-session state store defined here.
The main project-local Studio state store MUST be owned by `StudioProjectSession`, or by a persistence service explicitly owned by `StudioProjectSession`.
Shells and workspaces MUST consume that state through the project-session boundary.
They MUST NOT define ad hoc independent persistence contracts for layout, tabs, windows, or project-local setup.
The main project-session store MUST start as a `single file` under `.studio/`.
The first accepted scope of that store MUST include:
- project-local setup required by Studio to operate on the project, including paths such as the Prometeu runtime path;
- shell layout state such as splitter positions and layout proportions;
- open shell/workspace state such as which panels, windows, or workspaces were open;
- editor restoration state including open tabs and the active tab.
`Project-local setup` is a first-class category from the start, even before a dedicated UI exists to edit it.
The Studio MAY consume such setup programmatically before a UI surface exists.
`Project-local activity` MUST remain outside the main project-session store defined by this decision.
It MAY continue to live under `.studio/`, but it SHALL be treated as a separate concern with its own storage contract and persistence policy.
The main project-session store MUST NOT be used for:
- derived cache;
- ephemeral hover or transient interaction state;
- recomputable operational results;
- heavy operational output;
- activity history.
## Rationale
The repository already converged on two architectural directions:
- `.studio/` is the reserved root for project-local Studio concerns;
- `StudioProjectSession` is the correct owner for state that must survive workspace focus changes while remaining scoped to one opened project.
This decision extends those directions into a normative persistence contract.
The chosen model prevents three failure modes:
1. launcher-global persistence swallowing project-local setup and organization;
2. each shell or workspace inventing its own persistence format under `.studio/`;
3. the main session state store absorbing unrelated concerns such as activity history or cache data.
Starting with a `single file` is intentionally conservative.
It reduces governance and maintenance cost for the first wave while preserving the option to evolve the schema later if needed.
Treating project-local setup as first-class from the start is necessary because Studio runtime behavior may depend on local project configuration before any settings UI is available.
## Technical Specification
### 1. Storage Root
The canonical root for project-local Studio persistence MUST be `.studio/` in the project root.
The main project-session state store MUST be created under that root.
### 2. Ownership Boundary
`StudioProjectSession` MUST own the lifecycle of the main project-session state store.
That ownership includes:
- loading project-local setup when the project session opens;
- exposing the loaded state to shell and workspace consumers through project-session-owned contracts;
- coordinating restoration when the project shell mounts;
- coordinating persistence when the project shell or project session closes.
No workspace MAY bypass this boundary by defining an unrelated primary store for project-local Studio organization or setup.
### 3. Store Shape
The main store MUST begin as a `single file`.
This file MUST act as the canonical snapshot for the categories accepted by this decision.
The schema MUST be explicitly versioned so future revisions can detect compatibility and migration boundaries.
### 4. Accepted Wave-1 Categories
Wave 1 of the main project-session store MUST cover:
- `projectLocalSetup`
Project-local configuration required by Studio to operate on the project, including Prometeu runtime path and equivalent future setup fields.
- `shellLayout`
Restorable shell organization such as divider positions, layout proportions, and equivalent shell-level geometry state.
- `openShellState`
Which panels, windows, or workspaces were open when the project shell last persisted.
- `editorRestoration`
Open editor tabs and the active tab.
These names are conceptual categories.
Plans MAY refine the concrete field names, but they MUST preserve the exact normative scope above.
### 5. Explicit Exclusions
The main project-session store MUST NOT include:
- activity history;
- derived cache data;
- ephemeral UI interaction state such as hover-only data;
- heavy operational logs or output buffers;
- recomputable results that do not represent project-local Studio setup or restoration state.
If a future category is proposed, it MUST be classified explicitly as either:
- main project-session state;
- separate `.studio/` concern;
- launcher-global concern;
- or non-persisted transient state.
### 6. Read / Restore / Save Policy
The main policy is normatively locked as follows:
- project-local setup MUST be read when `StudioProjectSession` opens;
- restorable shell and editor state MUST be restored when the project shell mounts;
- the main project-session store MUST be saved when the project shell or project session closes.
The repository default for this store is `save on close`.
General incremental persistence MUST NOT be the default policy for the main store.
Checkpoints MAY be introduced only for categories that explicitly require intermediate persistence.
Any such checkpoint behavior MUST be justified in a later plan or revision and MUST remain category-scoped rather than becoming an uncontrolled global autosave rule.
### 7. Failure Handling
If `.studio/` is absent, the main store is missing, the schema is unknown, or the content is invalid or partially corrupted, the Studio MUST fall back to safe default values.
Such conditions MUST NOT block opening the project.
The fallback behavior MUST preserve the ability to open the project and operate the Studio with default shell, workspace, and setup assumptions as far as possible.
## Constraints
- No plan may reinterpret launcher-global state as project-local state without an explicit revision to this decision.
- No plan may move `project-local activity` into the main project-session store without an explicit revision to this decision.
- No plan may replace the `single file` starting point with a multi-file tree by inference.
- No plan may treat ephemeral UI noise or derived cache as accepted wave-1 project-session state.
- If a later implementation needs checkpoint persistence for a specific category, that need MUST be justified explicitly instead of silently broadening the save policy.
## Revision Log
- 2026-04-04: Initial accepted decision from AGD-0019.

View File

@ -0,0 +1,217 @@
---
id: PLN-0037
ticket: studio-project-local-studio-state-under-dot-studio
title: Project-local Studio state store and restoration
status: done
created: 2026-04-04
completed: 2026-04-04
tags: [studio, project-session, project-state, persistence, dot-studio, shell, layout, setup]
---
## Objective
Implement the first accepted wave of project-local Studio state persistence under `.studio/`, using a single-file project-session-owned store that restores setup, shell organization, and editor tabs for a project.
## Background
DEC-0016 normatively locks the following:
- project-local Studio state must live under `.studio/` in the project root;
- the main store must be owned by `StudioProjectSession`;
- the main store must start as a single file;
- wave 1 must include project-local setup, shell layout, open shell/workspace state, and editor restoration;
- project-local activity must remain outside the main store;
- the policy is read on project-session open, restore on shell mount, and save on shell/project close;
- missing or invalid persisted state must fall back to safe defaults without blocking project open.
The current codebase already has a `StudioProjectSession` boundary plus a `MainView` shell and an `EditorWorkspace` with in-memory tab/session state, but it has no project-local persistence contract for those concerns.
## Scope
### Included
- Propagate DEC-0016 into Studio specifications.
- Introduce a project-session-owned single-file state store under `.studio/`.
- Load project-local setup when a project session opens.
- Restore shell layout and open shell/workspace state when the project shell mounts.
- Restore editor open tabs and active tab from project-local persisted state.
- Save the main project-session store when the shell or project session closes.
- Fall back to default values when persisted state is absent, unknown, or invalid.
- Add tests for store parsing, fallback behavior, session ownership, shell restoration, and editor tab restoration.
### Excluded
- Project-local activity persistence changes.
- General autosave or global incremental persistence for the main store.
- Multi-file store layout under `.studio/`.
- New settings UI for project-local setup editing.
- Persistence of ephemeral hover state, derived cache, heavy logs, or recomputable operational output.
## Execution Steps
### Step 1 - Propagate DEC-0016 into Studio specs
**What:** Add normative spec coverage for the project-local Studio state store and its wave-1 boundaries.
**How:** Update the existing shell and editor specs, and add a dedicated Studio persistence spec if needed, so the repository explicitly states:
- `.studio/` is the project-local root for the main Studio state store;
- `StudioProjectSession` owns load/save lifecycle for the store;
- the store begins as a single file;
- wave-1 categories are `projectLocalSetup`, `shellLayout`, `openShellState`, and `editorRestoration`;
- `project-local activity` is excluded from the main store;
- read/restore/save policy and default fallback behavior are normative.
The preferred propagation shape is:
- update [`docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md`](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/docs/specs/studio/1.%20Studio%20Shell%20and%20Workspace%20Layout%20Specification.md) for shell/session ownership and restore/save timing;
- update [`docs/specs/studio/5. Code Editor Workspace Specification.md`](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/docs/specs/studio/5.%20Code%20Editor%20Workspace%20Specification.md) to replace the current “no cross-session tab restoration” rule with the accepted wave-1 restoration contract;
- add a new dedicated spec such as [`docs/specs/studio/8. Project-Local Studio State Specification.md`](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/docs/specs/studio/8.%20Project-Local%20Studio%20State%20Specification.md) to hold the store schema boundary and exclusions.
**File(s):**
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/docs/specs/studio/5. Code Editor Workspace Specification.md`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/docs/specs/studio/8. Project-Local Studio State Specification.md`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/docs/specs/studio/README.md` if the new spec must be indexed
### Step 2 - Define the project-session state store contract and single-file codec
**What:** Create the main persisted-state model and the single-file read/write boundary used by `StudioProjectSession`.
**How:** Introduce a small persistence module or package in `prometeu-studio` that defines:
- a versioned root DTO for the main state file;
- wave-1 category DTOs for `projectLocalSetup`, `shellLayout`, `openShellState`, and `editorRestoration`;
- a file path convention under `.studio/`;
- loader/writer logic that returns safe defaults when the file is absent, unreadable, schema-incompatible, or invalid.
The implementation must keep `project-local activity` out of this file and must not broaden the schema to cache-like or transient UI data.
**File(s):**
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectsessions/`
- a new package for state persistence such as `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectstate/`
- corresponding test files under `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/test/java/p/studio/`
### Step 3 - Extend StudioProjectSession to load setup and expose restorable state
**What:** Move the main store lifecycle into the project-session boundary.
**How:** Update `StudioProjectSession` and `StudioProjectSessionFactory` so opening a project session:
- loads the main store from `.studio/`;
- exposes the loaded state to shell/workspace consumers through explicit accessors or a dedicated session-owned state service;
- makes project-local setup available before workspace-specific UI mounts.
The session boundary must remain the owner of the persistence lifecycle; `MainView` and workspaces may consume the state but must not redefine load semantics.
**File(s):**
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java`
- any new project-session-owned state service under `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectsessions/` or `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectstate/`
### Step 4 - Restore shell layout and open shell/workspace state from the session store
**What:** Apply persisted shell organization when the project shell mounts and capture it again when the shell closes.
**How:** Update `MainView` and any shell controls it owns so they can:
- restore the selected/open workspace state from the session store instead of hardcoding the default assets workspace when persisted state exists;
- restore accepted shell layout properties such as splitter positions or equivalent shell-level geometry once the relevant shell surfaces are mounted;
- collect the latest shell state before the shell or project session closes so `save on close` persists the canonical snapshot.
If a specific shell surface does not yet expose restorable layout state, introduce the minimal shell-owned API needed to read/write that state without making the shell controls own persistence themselves.
**File(s):**
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/window/MainView.java`
- shell controls under `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/controls/shell/`
- application/window close wiring where the current Studio shell lifecycle is finalized
### Step 5 - Restore editor open tabs and active tab through an editor-owned snapshot contract
**What:** Persist and restore the editor workspaces accepted wave-1 restoration state.
**How:** Refactor the editor workspace so it can:
- export a pure restoration snapshot containing open tab paths and active tab path;
- accept a restoration snapshot when the workspace mounts under a project session;
- reopen supported files through `prometeu-vfs` using those persisted paths;
- fall back safely when a previously open file no longer exists or is no longer supported;
- preserve the active-tab choice when restoration succeeds.
This step must not broaden into persistence of editor-local transient data beyond what DEC-0016 accepts.
**File(s):**
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java` if small UI adjustments are needed for restore-first behavior
### Step 6 - Wire save-on-close and category-scoped checkpoints if any are required
**What:** Make the accepted save policy real in the application lifecycle.
**How:** Ensure the main store is serialized:
- when the project shell closes;
- and when the project session closes through its normal lifecycle.
Do not introduce general autosave for the main store.
Only add checkpoint saves if a specific accepted category proves it cannot safely rely on close-time persistence alone, and keep that logic isolated to that category.
**File(s):**
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/window/MainView.java`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java`
- any shell/window lifecycle integration surface that currently owns project-window close behavior
### Step 7 - Add tests for defaults, restoration, and persistence boundaries
**What:** Lock the decision through automated tests.
**How:** Add tests that verify:
- missing `.studio/` falls back to defaults;
- invalid or schema-incompatible persisted content falls back to defaults;
- project-local setup is loaded at project-session open;
- shell/workspace state is restored from persisted state when present;
- editor open tabs and active tab restore correctly;
- `project-local activity` is not part of the main store contract;
- saving the main store happens on the accepted close boundary.
**File(s):**
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/test/java/p/studio/projectsessions/`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/test/java/p/studio/window/`
- `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/test/java/p/studio/workspaces/editor/`
- tests for the new persistence package under `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/test/java/p/studio/projectstate/`
## Test Requirements
### Unit Tests
- Persisted-state codec tests for versioned single-file read/write behavior.
- Fallback tests for absent, invalid, and schema-incompatible state files.
- Session-service tests proving default values are exposed when persisted state is unusable.
- Editor snapshot tests proving only open-tab paths and active-tab identity are persisted.
### Integration Tests
- `StudioProjectSessionFactory` tests that verify setup is loaded during session open.
- Shell or window tests that verify persisted workspace selection and shell state are applied on mount.
- Editor workspace tests that reopen tabs from persisted paths and preserve the active tab when possible.
- Close-lifecycle tests that verify the main store is written when the shell or session closes.
### Manual Verification
- Open a project with no `.studio/` state and confirm the Studio boots with defaults.
- Configure project-local setup such as a Prometeu runtime path, close the shell, reopen the project, and confirm the setup is restored.
- Open multiple editor tabs, switch the active tab, close the project, reopen it, and confirm the same tabs and active tab are restored.
- Change shell/workspace organization that is in scope for wave 1, close the project, reopen it, and confirm that layout/workspace state is restored.
- Corrupt or remove the persisted state file and confirm the project still opens with defaults instead of failing.
## Acceptance Criteria
- [ ] Studio specs normatively describe the `.studio/` single-file project-session store and its exclusions.
- [ ] `StudioProjectSession` owns loading of project-local setup and access to the main state store.
- [ ] The main store is persisted as a single file under `.studio/`.
- [ ] Wave-1 categories are implemented without admitting excluded categories such as activity history or derived cache.
- [ ] Shell/workspace state restores on shell mount and saves on shell/project close.
- [ ] Editor open tabs and active tab restore from persisted project-local state.
- [ ] Missing or invalid persisted state falls back to safe defaults without blocking project open.
## Dependencies
- Accepted decision [`DEC-0016-project-local-studio-state-under-dot-studio.md`](/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/discussion/workflow/decisions/DEC-0016-project-local-studio-state-under-dot-studio.md)
- Existing project-session boundary in `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/projectsessions/`
- Existing editor tab/session behavior in `/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/prometeu-studio/src/main/java/p/studio/workspaces/editor/`
## Risks
- The current shell controls may not yet expose enough structured state to restore all accepted wave-1 layout fields cleanly.
- Editor restoration may need a small refactor to separate pure restore snapshots from current UI-driven open-file behavior.
- A new persistence spec may overlap with current shell/editor specs unless propagation boundaries are written carefully.
- Save-on-close behavior may depend on application/window lifecycle code not yet centralized in one obvious surface.

View File

@ -79,6 +79,9 @@ Rules:
- the shell may construct and own project-session services that survive workspace focus changes;
- workspaces may consume those project-session services without inheriting their ownership;
- introducing a project-session service must not collapse shell and workspace responsibilities into one monolithic root.
- project-local Studio state under `.studio/` must be loaded through the project-session boundary rather than through shell-local ad hoc persistence;
- project-local setup required before workspace use must be available when the project session opens;
- shell-restorable state accepted by Studio must be restored when the project shell mounts and persisted when the project shell closes.
## Right Utility Panel

View File

@ -174,8 +174,16 @@ The first wave must not define:
- tab pinning,
- drag-and-drop tab reordering,
- grouped tabs,
- split editors,
- or cross-session tab restoration.
- split editors.
Project-session-owned cross-session tab restoration is accepted for the project-local Studio state wave.
Rules:
- persisted editor restoration state must come from the project-session-owned `.studio/` state store rather than from an editor-owned ad hoc file;
- accepted restoration scope in this wave is limited to open tabs and active tab;
- editor layout restoration accepted in this wave may include divider-driven editor sizing, `Project Navigator` expansion state, and dock-panel configuration such as `Outline` and `Helper` open/closed state plus their restored size;
- restoration must degrade safely when a previously open file is missing or unsupported.
## Access-Mode and Save Model

View File

@ -0,0 +1,85 @@
# Project-Local Studio State Specification
## Status
Active
## Applies To
- `prometeu-studio`
- Studio project-session persistence under `.studio/`
- project-local Studio setup and restoration state
## Purpose
Define the normative contract for the main project-local Studio state store.
## Authority and Precedence
This specification implements the accepted decision for project-local Studio state under `.studio/`.
It extends:
- [`1. Studio Shell and Workspace Layout Specification.md`](1.%20Studio%20Shell%20and%20Workspace%20Layout%20Specification.md)
- [`5. Code Editor Workspace Specification.md`](5.%20Code%20Editor%20Workspace%20Specification.md)
## Core Rules
1. The canonical root for project-local Studio persistence MUST be `.studio/` in the project root.
2. The main project-local Studio state store MUST be owned by `StudioProjectSession`, or by a service explicitly owned by `StudioProjectSession`.
3. The main store MUST start as a single file.
4. The main store MUST be versioned.
5. The main store MUST load when the project session opens.
6. Restorable shell and editor state accepted by this wave MUST restore when the project shell mounts.
7. The main store MUST save when the project shell or project session closes.
8. Missing, invalid, or schema-incompatible state MUST fall back to safe default values and MUST NOT block project open.
## Accepted Wave-1 Categories
The main store MUST include:
- `projectLocalSetup`
- `shellLayout`
- `openShellState`
- `editorRestoration`
Wave-1 accepted meaning is:
- `projectLocalSetup`: project-local Studio setup required to operate on the project, including runtime paths such as the Prometeu runtime path;
- `shellLayout`: restorable layout state already accepted by implementation, including divider positions and equivalent layout proportions for accepted workspaces such as `Assets` and `Code Editor`, `Project Navigator` expansion state, and editor dock-panel open/closed plus restored-size configuration;
- `openShellState`: which workspace or equivalent shell-owned project view was open;
- `editorRestoration`: open editor tabs and active tab.
## Explicit Exclusions
The main store MUST NOT include:
- project-local activity history;
- derived cache;
- transient hover-only or equivalent ephemeral UI state;
- heavy operational output;
- recomputable results that do not represent project-local setup or restoration state.
`Project-local activity` may still live under `.studio/`, but it is a separate persistence concern and not part of the main store defined here.
## Ownership Rules
- shell and workspace UI may consume the main store through project-session-owned contracts;
- shell and workspace UI must not define competing primary persistence files for accepted wave-1 categories;
- project-local setup may be consumed before a dedicated settings UI exists.
## Non-Goals
- defining persistence for project-local activity
- introducing a multi-file main store in this wave
- defining a settings editor UI for project-local setup
- persisting arbitrary transient workspace detail by convenience
## Exit Criteria
This specification is complete enough when:
- the `.studio/` boundary is unambiguous;
- accepted and excluded categories are explicit;
- load, restore, save, and fallback timing are explicit;
- and the ownership boundary with `StudioProjectSession` is normatively locked.

View File

@ -57,6 +57,7 @@ The current Studio core corpus is:
5. [`5. Code Editor Workspace Specification.md`](5.%20Code%20Editor%20Workspace%20Specification.md)
6. [`6. Project Document VFS Specification.md`](6.%20Project%20Document%20VFS%20Specification.md)
7. [`7. Integrated LSP Semantic Read Phase Specification.md`](7.%20Integrated%20LSP%20Semantic%20Read%20Phase%20Specification.md)
8. [`8. Project-Local Studio State Specification.md`](8.%20Project-Local%20Studio%20State%20Specification.md)
## Reading Order
@ -68,7 +69,8 @@ Recommended order:
4. assets workspace behavior;
5. project document VFS boundary;
6. code editor workspace behavior;
7. integrated LSP semantic-read behavior.
7. integrated LSP semantic-read behavior;
8. project-local Studio state behavior.
## Current Wave Notes

View File

@ -112,6 +112,26 @@ public abstract class WorkspaceDockPane extends TitledPane {
return split;
}
public final DockState captureDockState() {
final double preferredExpandedDivider = lastExpandedDividerPosition != null
? lastExpandedDividerPosition
: currentDivider();
return new DockState(isExpanded(), preferredExpandedDivider);
}
public final void restoreDockState(final DockState dockState) {
if (dockState == null) {
return;
}
lastExpandedDividerPosition = dockState.dividerPosition();
setExpanded(dockState.expanded());
if (dockState.expanded()) {
setDivider(clampExpandedDivider(dockState.dividerPosition()));
} else if (dockSplit != null) {
applyCollapsedDivider();
}
}
protected final void setDockContent(final Node content) {
setContent(Objects.requireNonNull(content, "content"));
}
@ -227,4 +247,7 @@ public abstract class WorkspaceDockPane extends TitledPane {
node.setManaged(!disabled);
}));
}
public record DockState(boolean expanded, double dividerPosition) {
}
}

View File

@ -6,6 +6,7 @@ import java.util.Objects;
public final class ProjectStudioPaths {
private static final String STUDIO_DIR = ".studio";
private static final String ACTIVITIES_FILE = "activities.json";
private static final String STATE_FILE = "state.json";
private ProjectStudioPaths() {
}
@ -24,4 +25,11 @@ public final class ProjectStudioPaths {
.toAbsolutePath()
.normalize();
}
public static Path statePath(ProjectReference projectReference) {
return studioRoot(projectReference)
.resolve(STATE_FILE)
.toAbsolutePath()
.normalize();
}
}

View File

@ -1,6 +1,8 @@
package p.studio.projectsessions;
import p.studio.lsp.LspService;
import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projectstate.ProjectLocalStudioStateService;
import p.studio.projects.ProjectReference;
import p.studio.vfs.VfsProjectDocument;
@ -10,15 +12,33 @@ public final class StudioProjectSession implements AutoCloseable {
private final ProjectReference projectReference;
private final LspService prometeuLspService;
private final VfsProjectDocument vfsProjectDocument;
private final ProjectLocalStudioStateService projectLocalStudioStateService;
private ProjectLocalStudioState projectLocalStudioState;
private boolean closed;
public StudioProjectSession(
final ProjectReference projectReference,
final LspService prometeuLspService,
final VfsProjectDocument vfsProjectDocument) {
this(
projectReference,
prometeuLspService,
vfsProjectDocument,
new ProjectLocalStudioStateService(),
ProjectLocalStudioState.defaults());
}
StudioProjectSession(
final ProjectReference projectReference,
final LspService prometeuLspService,
final VfsProjectDocument vfsProjectDocument,
final ProjectLocalStudioStateService projectLocalStudioStateService,
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.projectLocalStudioState = Objects.requireNonNull(projectLocalStudioState, "projectLocalStudioState");
}
public ProjectReference projectReference() {
@ -33,13 +53,50 @@ public final class StudioProjectSession implements AutoCloseable {
return prometeuLspService;
}
public ProjectLocalStudioState projectLocalStudioState() {
return projectLocalStudioState;
}
public void replaceProjectLocalStudioState(final ProjectLocalStudioState nextProjectLocalStudioState) {
this.projectLocalStudioState = Objects.requireNonNull(nextProjectLocalStudioState, "nextProjectLocalStudioState");
}
public void saveProjectLocalStudioState() {
projectLocalStudioStateService.save(projectReference, projectLocalStudioState);
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
RuntimeException failure = null;
try {
saveProjectLocalStudioState();
} catch (RuntimeException runtimeException) {
failure = runtimeException;
}
try {
prometeuLspService.close();
} catch (RuntimeException runtimeException) {
if (failure == null) {
failure = runtimeException;
} else {
failure.addSuppressed(runtimeException);
}
}
try {
vfsProjectDocument.close();
} catch (RuntimeException runtimeException) {
if (failure == null) {
failure = runtimeException;
} else {
failure.addSuppressed(runtimeException);
}
}
if (failure != null) {
throw failure;
}
}
}

View File

@ -2,6 +2,7 @@ package p.studio.projectsessions;
import p.studio.lsp.messages.LspProjectContext;
import p.studio.lsp.LspServiceFactory;
import p.studio.projectstate.ProjectLocalStudioStateService;
import p.studio.projects.ProjectReference;
import p.studio.vfs.VfsProjectDocument;
import p.studio.vfs.ProjectDocumentVfsFactory;
@ -11,12 +12,21 @@ import java.util.Objects;
public final class StudioProjectSessionFactory {
private final LspServiceFactory lspServiceFactory;
private final ProjectDocumentVfsFactory projectDocumentVfsFactory;
private final ProjectLocalStudioStateService projectLocalStudioStateService;
public StudioProjectSessionFactory(
final LspServiceFactory lspServiceFactory,
final ProjectDocumentVfsFactory projectDocumentVfsFactory) {
this(lspServiceFactory, projectDocumentVfsFactory, new ProjectLocalStudioStateService());
}
StudioProjectSessionFactory(
final LspServiceFactory lspServiceFactory,
final ProjectDocumentVfsFactory projectDocumentVfsFactory,
final ProjectLocalStudioStateService projectLocalStudioStateService) {
this.lspServiceFactory = Objects.requireNonNull(lspServiceFactory, "prometeuLspServiceFactory");
this.projectDocumentVfsFactory = Objects.requireNonNull(projectDocumentVfsFactory, "projectDocumentVfsFactory");
this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService");
}
public StudioProjectSession open(final ProjectReference projectReference) {
@ -25,7 +35,9 @@ public final class StudioProjectSessionFactory {
return new StudioProjectSession(
target,
lspServiceFactory.open(lspProjectContext(target), vfsProjectDocument),
vfsProjectDocument);
vfsProjectDocument,
projectLocalStudioStateService,
projectLocalStudioStateService.load(target));
}
private LspProjectContext lspProjectContext(final ProjectReference projectReference) {

View File

@ -0,0 +1,228 @@
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,
ProjectLocalSetup projectLocalSetup,
ShellLayout shellLayout,
OpenShellState openShellState,
EditorRestorationState editorRestoration) {
public static final int CURRENT_VERSION = 1;
public ProjectLocalStudioState {
version = version <= 0 ? CURRENT_VERSION : version;
projectLocalSetup = projectLocalSetup == null ? ProjectLocalSetup.defaults() : projectLocalSetup.normalize();
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,
ProjectLocalSetup.defaults(),
ShellLayout.defaults(),
OpenShellState.defaults(),
EditorRestorationState.defaults());
}
public ProjectLocalStudioState withShellLayout(final ShellLayout nextShellLayout) {
return new ProjectLocalStudioState(version, projectLocalSetup, nextShellLayout, openShellState, editorRestoration);
}
public ProjectLocalStudioState withOpenShellState(final OpenShellState nextOpenShellState) {
return new ProjectLocalStudioState(version, projectLocalSetup, shellLayout, nextOpenShellState, editorRestoration);
}
public ProjectLocalStudioState withEditorRestoration(final EditorRestorationState nextEditorRestoration) {
return new ProjectLocalStudioState(version, projectLocalSetup, shellLayout, openShellState, nextEditorRestoration);
}
public ProjectLocalStudioState withProjectLocalSetup(final ProjectLocalSetup nextProjectLocalSetup) {
return new ProjectLocalStudioState(version, nextProjectLocalSetup, shellLayout, openShellState, editorRestoration);
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ProjectLocalSetup(String prometeuRuntimePath) {
public ProjectLocalSetup {
prometeuRuntimePath = normalizeText(prometeuRuntimePath);
}
public static ProjectLocalSetup defaults() {
return new ProjectLocalSetup(null);
}
private ProjectLocalSetup normalize() {
return new ProjectLocalSetup(prometeuRuntimePath);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ShellLayout(EditorLayoutState editorLayout, 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());
}
private ShellLayout normalize() {
return new ShellLayout(editorLayout, assetsLayout);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record AssetsLayoutState(List<Double> contentSplitDividers) {
public AssetsLayoutState {
contentSplitDividers = normalizeDividerPositions(contentSplitDividers);
}
public static AssetsLayoutState defaults() {
return new AssetsLayoutState(List.of());
}
private AssetsLayoutState normalize() {
return new AssetsLayoutState(contentSplitDividers);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record OpenShellState(String selectedWorkspaceId) {
public OpenShellState {
selectedWorkspaceId = normalizeText(selectedWorkspaceId);
}
public static OpenShellState defaults() {
return new OpenShellState(null);
}
private OpenShellState normalize() {
return new OpenShellState(selectedWorkspaceId);
}
}
@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;
}
final String trimmed = value.trim();
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();
}
final List<Double> normalized = new ArrayList<>();
for (final Double value : values) {
if (value == null || value.isNaN() || value.isInfinite()) {
continue;
}
normalized.add(Math.max(0.0d, Math.min(1.0d, value)));
}
return List.copyOf(normalized);
}
private static Double normalizeDividerPosition(final Double value) {
if (value == null || value.isNaN() || value.isInfinite()) {
return null;
}
return Math.max(0.0d, Math.min(1.0d, value));
}
}

View File

@ -0,0 +1,45 @@
package p.studio.projectstate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import p.studio.projects.ProjectReference;
import p.studio.projects.ProjectStudioPaths;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
public class ProjectLocalStudioStateService {
private static final ObjectMapper MAPPER = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
public ProjectLocalStudioState load(final ProjectReference projectReference) {
final Path statePath = ProjectStudioPaths.statePath(projectReference);
if (!Files.isRegularFile(statePath)) {
return ProjectLocalStudioState.defaults();
}
try {
final ProjectLocalStudioState loaded = MAPPER.readValue(statePath.toFile(), ProjectLocalStudioState.class);
if (loaded == null || loaded.version() != ProjectLocalStudioState.CURRENT_VERSION) {
return ProjectLocalStudioState.defaults();
}
return loaded;
} catch (IOException | RuntimeException ignored) {
return ProjectLocalStudioState.defaults();
}
}
public void save(final ProjectReference projectReference, final ProjectLocalStudioState state) {
final Path statePath = ProjectStudioPaths.statePath(projectReference);
final ProjectLocalStudioState normalized = Objects.requireNonNull(state, "state");
try {
Files.createDirectories(statePath.getParent());
MAPPER.writeValue(statePath.toFile(), normalized);
} catch (IOException ioException) {
throw new UncheckedIOException(ioException);
}
}
}

View File

@ -4,6 +4,7 @@ import javafx.scene.layout.BorderPane;
import p.studio.Container;
import p.studio.controls.shell.*;
import p.studio.lsp.events.StudioWorkspaceSelectedEvent;
import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projects.ProjectReference;
import p.studio.projectsessions.StudioProjectSession;
import p.studio.utilities.i18n.I18n;
@ -19,23 +20,36 @@ public final class MainView extends BorderPane {
private final WorkspaceHost host = new WorkspaceHost();
private final ProjectReference projectReference;
private final StudioProjectSession projectSession;
private final AssetWorkspace assetWorkspace;
private final EditorWorkspace editorWorkspace;
private final StudioWorkspaceRailControl<WorkspaceId> workspaceRail;
private boolean initializing;
private boolean workspaceLifecycleInitialized;
public MainView(final StudioProjectSession projectSession) {
initializing = true;
this.projectSession = projectSession;
this.projectReference = projectSession.projectReference();
final ProjectLocalStudioState persistedState = projectSession.projectLocalStudioState();
final var menuBar = new StudioShellMenuBarControl();
final var runSurface = new StudioRunSurfaceControl();
setTop(new StudioShellTopBarControl(menuBar));
host.register(new AssetWorkspace(projectReference));
host.register(new EditorWorkspace(
assetWorkspace = new AssetWorkspace(projectReference);
host.register(assetWorkspace);
editorWorkspace = new EditorWorkspace(
projectReference,
projectSession.projectDocumentVfs(),
projectSession.prometeuLspService()));
projectSession.prometeuLspService());
host.register(editorWorkspace);
assetWorkspace.restoreProjectLocalState(persistedState);
editorWorkspace.restoreProjectLocalState(persistedState);
assetWorkspace.setStateChangedAction(this::persistProjectLocalState);
editorWorkspace.setStateChangedAction(this::persistProjectLocalState);
// host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug"));
host.register(new ShipperWorkspace(projectReference));
final var workspaceRail = new StudioWorkspaceRailControl<>(
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)),
@ -50,9 +64,11 @@ public final class MainView extends BorderPane {
Container.i18n().bind(I18n.SHELL_ACTIVITY),
new StudioActivityFeedControl(projectReference)));
// default
workspaceRail.select(WorkspaceId.ASSETS);
loadWorkspace(WorkspaceId.ASSETS);
final WorkspaceId initialWorkspace = restoreInitialWorkspace(persistedState.openShellState());
workspaceRail.select(initialWorkspace);
loadWorkspace(initialWorkspace);
workspaceLifecycleInitialized = true;
initializing = false;
}
public ProjectReference projectReference() {
@ -63,8 +79,43 @@ public final class MainView extends BorderPane {
return projectSession;
}
public void persistProjectLocalState() {
if (initializing) {
return;
}
final WorkspaceId selectedWorkspace = host.getCurrentWorkspaceId() == null ? WorkspaceId.ASSETS : host.getCurrentWorkspaceId();
final ProjectLocalStudioState current = projectSession.projectLocalStudioState();
projectSession.replaceProjectLocalStudioState(
current.withOpenShellState(new ProjectLocalStudioState.OpenShellState(selectedWorkspace.name()))
.withShellLayout(new ProjectLocalStudioState.ShellLayout(
editorWorkspace.captureLayoutState(),
assetWorkspace.captureLayoutState()))
.withEditorRestoration(editorWorkspace.captureRestorationState()));
}
public void reapplyProjectLocalLayout() {
assetWorkspace.reapplyPendingLayoutState();
editorWorkspace.reapplyPendingLayoutState();
}
private void loadWorkspace(WorkspaceId workspaceId) {
host.change(workspaceId);
if (workspaceLifecycleInitialized) {
persistProjectLocalState();
projectSession.saveProjectLocalStudioState();
}
Container.eventBus().publish(new StudioWorkspaceSelectedEvent(workspaceId));
}
private WorkspaceId restoreInitialWorkspace(final ProjectLocalStudioState.OpenShellState openShellState) {
if (openShellState.selectedWorkspaceId() == null) {
return WorkspaceId.ASSETS;
}
try {
return WorkspaceId.valueOf(openShellState.selectedWorkspaceId());
} catch (IllegalArgumentException ignored) {
return WorkspaceId.ASSETS;
}
}
}

View File

@ -146,7 +146,8 @@ public final class StudioWindowCoordinator {
private void finishProjectOpen(StudioProjectSession projectSession, Stage loadingStage) {
final ProjectReference projectReference = projectSession.projectReference();
final Stage projectStage = new Stage();
final Scene scene = new Scene(new MainView(projectSession), PROJECT_WIDTH, PROJECT_HEIGHT);
final MainView mainView = new MainView(projectSession);
final Scene scene = new Scene(mainView, PROJECT_WIDTH, PROJECT_HEIGHT);
scene.getStylesheets().add(Container.theme().getDefaultTheme());
projectStage.setTitle(Container.i18n().format(I18n.APP_PROJECT_TITLE, projectReference.name()));
@ -169,6 +170,7 @@ public final class StudioWindowCoordinator {
loadingStage.close();
projectStage.show();
projectStage.toFront();
Platform.runLater(mainView::reapplyProjectLocalLayout);
}
private void failProjectOpen(

View File

@ -9,6 +9,7 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import p.studio.Container;
import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.Workspace;
@ -27,6 +28,10 @@ public final class AssetWorkspace extends Workspace {
private final AssetListControl assetListControl;
private final AssetDetailsControl detailsControl;
private final AssetLogsPane logsPane;
private SplitPane splitPane;
private Runnable stateChangedAction = () -> { };
private ProjectLocalStudioState.AssetsLayoutState pendingAssetsLayoutState = ProjectLocalStudioState.AssetsLayoutState.defaults();
private boolean applyingPendingLayoutState;
public AssetWorkspace(final ProjectReference projectReference) {
super(projectReference);
@ -55,6 +60,8 @@ public final class AssetWorkspace extends Workspace {
@Override
public void load() {
reapplyPendingLayoutState();
schedulePendingLayoutReapply();
workspaceEventBus.publish(new StudioAssetsRefreshRequestedEvent());
}
@ -67,6 +74,37 @@ public final class AssetWorkspace extends Workspace {
return List.of(assetListControl, detailsControl, logsPane);
}
public void setStateChangedAction(final Runnable stateChangedAction) {
this.stateChangedAction = java.util.Objects.requireNonNull(stateChangedAction, "stateChangedAction");
}
public void restoreProjectLocalState(final ProjectLocalStudioState projectLocalStudioState) {
pendingAssetsLayoutState = projectLocalStudioState.shellLayout().assetsLayout();
reapplyPendingLayoutState();
schedulePendingLayoutReapply();
}
public void reapplyPendingLayoutState() {
if (splitPane == null || pendingAssetsLayoutState.contentSplitDividers().isEmpty()) {
return;
}
applyingPendingLayoutState = true;
splitPane.setDividerPositions(pendingAssetsLayoutState.contentSplitDividers().stream()
.mapToDouble(Double::doubleValue)
.toArray());
javafx.application.Platform.runLater(() -> applyingPendingLayoutState = false);
}
public ProjectLocalStudioState.AssetsLayoutState captureLayoutState() {
if (splitPane == null || splitPane.getDividers().isEmpty()) {
return ProjectLocalStudioState.AssetsLayoutState.defaults();
}
return new ProjectLocalStudioState.AssetsLayoutState(
splitPane.getDividers().stream()
.map(SplitPane.Divider::getPosition)
.toList());
}
private VBox buildLayout() {
final var topProgress = createTopProgressBox();
final var actionBar = createActionBar();
@ -144,10 +182,37 @@ public final class AssetWorkspace extends Workspace {
}
private SplitPane createAssetSplitPane() {
final var splitPane = new SplitPane(assetListControl, detailsControl);
splitPane = new SplitPane(assetListControl, detailsControl);
splitPane.setDividerPositions(0.34);
splitPane.getStyleClass().add("assets-workspace-split");
splitPane.getDividers().forEach(divider ->
divider.positionProperty().addListener((ignored, previous, current) -> {
if (applyingPendingLayoutState) {
return;
}
pendingAssetsLayoutState = captureLayoutState();
stateChangedAction.run();
}));
VBox.setVgrow(splitPane, Priority.ALWAYS);
return splitPane;
}
private void schedulePendingLayoutReapply() {
if (splitPane == null) {
return;
}
if (splitPane.getWidth() > 0) {
javafx.application.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;
}
splitPane.widthProperty().removeListener(listenerRef[0]);
javafx.application.Platform.runLater(this::reapplyPendingLayoutState);
};
splitPane.widthProperty().addListener(listenerRef[0]);
}
}

View File

@ -1,5 +1,7 @@
package p.studio.workspaces.editor;
import p.studio.projectstate.ProjectLocalStudioState;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@ -51,6 +53,19 @@ public final class EditorOpenFileSession {
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) {

View File

@ -19,7 +19,9 @@ 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;
@ -34,6 +36,7 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
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");
@ -129,13 +132,42 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
public void setSnapshot(final VfsProjectSnapshot snapshot) {
final var currentSelection = selectedPath();
final var expandedPaths = captureExpandedPaths(treeView.getRoot());
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();
@ -180,6 +212,12 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
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)

View File

@ -15,6 +15,7 @@ import org.reactfx.Subscription;
import p.studio.lsp.LspService;
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.vfs.VfsProjectDocument;
@ -55,6 +56,12 @@ public final class EditorWorkspace extends Workspace {
presentationRegistry.resolve("text").highlight(""),
List.of());
private boolean syncingEditor;
private boolean applyingPendingLayoutState;
private Runnable stateChangedAction = () -> { };
private ProjectLocalStudioState.EditorLayoutState pendingEditorLayoutState = ProjectLocalStudioState.EditorLayoutState.defaults();
private SplitPane contentSplit;
private SplitPane leftColumnSplit;
private SplitPane rightColumnSplit;
public EditorWorkspace(
final ProjectReference projectReference,
@ -91,6 +98,7 @@ public final class EditorWorkspace extends Workspace {
navigatorPanel.setFileSelectionAction(this::openNode);
tabStrip.setTabSelectionAction(path -> {
openFileSession.activate(path);
notifyStateChanged();
renderSession();
});
@ -104,6 +112,8 @@ public final class EditorWorkspace extends Workspace {
@Override
public void load() {
applyEditorLayoutState(pendingEditorLayoutState);
schedulePendingLayoutReapply();
refreshNavigator();
}
@ -114,6 +124,36 @@ public final class EditorWorkspace extends Workspace {
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());
}
@ -129,6 +169,7 @@ public final class EditorWorkspace extends Workspace {
private void openFile(final VfsDocumentOpenResult.VfsTextDocument textDocument) {
openFileSession.open(bufferFrom(textDocument));
notifyStateChanged();
renderSession();
}
@ -254,19 +295,23 @@ public final class EditorWorkspace extends Workspace {
}
private VBox buildLayout() {
final var content = new SplitPane(buildLeftColumn(), buildRightColumn());
content.setDividerPositions(0.30);
content.getStyleClass().add("editor-workspace-split");
contentSplit = new SplitPane(buildLeftColumn(), buildRightColumn());
contentSplit.setDividerPositions(0.30);
contentSplit.getStyleClass().add("editor-workspace-split");
observeStatefulSplitPane(contentSplit);
final var layout = new VBox(12, buildCommandBar(), content, statusBar);
final var layout = new VBox(12, buildCommandBar(), contentSplit, statusBar);
layout.getStyleClass().add("editor-workspace-layout");
VBox.setVgrow(content, Priority.ALWAYS);
VBox.setVgrow(contentSplit, Priority.ALWAYS);
return layout;
}
private SplitPane buildLeftColumn() {
final var leftColumn = outlinePanel.createBottomDockLayout(navigatorPanel, "editor-workspace-left-split");
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;
}
@ -284,7 +329,10 @@ public final class EditorWorkspace extends Workspace {
}
private SplitPane buildRightColumn() {
return helperPanel.createBottomDockLayout(buildCenterColumn(), "editor-workspace-right-split");
rightColumnSplit = helperPanel.createBottomDockLayout(buildCenterColumn(), "editor-workspace-right-split");
observeStatefulSplitPane(rightColumnSplit);
observeDockPane(helperPanel);
return rightColumnSplit;
}
private void configureCommandBar() {
@ -454,4 +502,134 @@ public final class EditorWorkspace extends Workspace {
codeArea.moveTo(inlineHintProjection.clampCaret(caret, true));
}
}
private void restoreEditorState(final ProjectLocalStudioState.EditorRestorationState editorRestorationState) {
openFileSession.clear();
for (final String pathText : editorRestorationState.openTabPaths()) {
tryRestoreOpenFile(pathText);
}
if (editorRestorationState.activeTabPath() != null) {
try {
openFileSession.activate(Path.of(editorRestorationState.activeTabPath()));
} catch (RuntimeException ignored) {
// Fall back to the currently active restored tab.
}
}
renderSession();
}
private void tryRestoreOpenFile(final String pathText) {
try {
final Path path = Path.of(pathText).toAbsolutePath().normalize();
final VfsDocumentOpenResult result = vfsProjectDocument.openDocument(path);
if (result instanceof VfsDocumentOpenResult.VfsTextDocument textDocument) {
openFileSession.open(bufferFrom(textDocument));
}
} catch (RuntimeException ignored) {
// Invalid or unavailable persisted tabs are skipped and the editor falls back to the remaining state.
}
}
private List<Double> dividerPositions(final SplitPane splitPane) {
if (splitPane == null || splitPane.getDividers().isEmpty()) {
return List.of();
}
return splitPane.getDividers().stream()
.map(SplitPane.Divider::getPosition)
.toList();
}
private void applyDividerPositions(final SplitPane splitPane, final List<Double> positions) {
if (splitPane == null || positions == null || positions.isEmpty()) {
return;
}
final double[] values = positions.stream().mapToDouble(Double::doubleValue).toArray();
splitPane.setDividerPositions(values);
}
private ProjectLocalStudioState.DockPanelState dockPanelState(final p.studio.controls.WorkspaceDockPane dockPane) {
final p.studio.controls.WorkspaceDockPane.DockState state = dockPane.captureDockState();
return new ProjectLocalStudioState.DockPanelState(state.expanded(), state.dividerPosition());
}
private void restoreDockPanelState(
final p.studio.controls.WorkspaceDockPane dockPane,
final ProjectLocalStudioState.DockPanelState dockPanelState) {
if (dockPanelState == null) {
return;
}
final double dividerPosition = dockPanelState.dividerPosition() == null
? dockPane.captureDockState().dividerPosition()
: dockPanelState.dividerPosition();
dockPane.restoreDockState(new p.studio.controls.WorkspaceDockPane.DockState(
dockPanelState.expanded(),
dividerPosition));
}
private void observeStatefulSplitPane(final SplitPane splitPane) {
splitPane.getDividers().forEach(divider ->
divider.positionProperty().addListener((ignored, previous, current) -> {
if (applyingPendingLayoutState) {
return;
}
notifyStateChanged();
}));
splitPane.getDividers().addListener((javafx.collections.ListChangeListener<? super SplitPane.Divider>) change -> {
while (change.next()) {
if (change.wasAdded()) {
change.getAddedSubList().forEach(divider ->
divider.positionProperty().addListener((ignored, previous, current) -> {
if (applyingPendingLayoutState) {
return;
}
notifyStateChanged();
}));
}
}
});
}
private void observeDockPane(final p.studio.controls.WorkspaceDockPane dockPane) {
dockPane.expandedProperty().addListener((ignored, previous, current) -> {
if (applyingPendingLayoutState) {
return;
}
notifyStateChanged();
});
}
private void notifyStateChanged() {
pendingEditorLayoutState = captureLayoutState();
stateChangedAction.run();
}
private void schedulePendingLayoutReapply() {
if (contentSplit == null) {
return;
}
if (contentSplit.getWidth() > 0) {
Platform.runLater(this::reapplyPendingLayoutState);
return;
}
final javafx.beans.value.ChangeListener<Number>[] listenerRef = new javafx.beans.value.ChangeListener[1];
listenerRef[0] = (ignored, previous, current) -> {
if (current == null || current.doubleValue() <= 0) {
return;
}
contentSplit.widthProperty().removeListener(listenerRef[0]);
Platform.runLater(this::reapplyPendingLayoutState);
};
contentSplit.widthProperty().addListener(listenerRef[0]);
}
private void applyEditorLayoutState(final ProjectLocalStudioState.EditorLayoutState editorLayoutState) {
applyingPendingLayoutState = true;
applyDividerPositions(contentSplit, editorLayoutState.contentSplitDividers());
applyDividerPositions(leftColumnSplit, editorLayoutState.leftColumnDividers());
applyDividerPositions(rightColumnSplit, editorLayoutState.rightColumnDividers());
restoreDockPanelState(outlinePanel, editorLayoutState.outlinePanel());
restoreDockPanelState(helperPanel, editorLayoutState.helperPanel());
navigatorPanel.restoreExpandedPaths(editorLayoutState.navigatorExpandedPaths());
Platform.runLater(() -> applyingPendingLayoutState = false);
}
}

View File

@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test;
import p.studio.lsp.messages.LspProjectContext;
import p.studio.lsp.LspService;
import p.studio.lsp.LspServiceFactory;
import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projectstate.ProjectLocalStudioStateService;
import p.studio.lsp.dtos.LspSessionStateDTO;
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
@ -27,7 +29,8 @@ final class StudioProjectSessionFactoryTest {
void openCreatesProjectSessionBackedByProjectScopedVfs() {
final RecordingLspFactory lspFactory = new RecordingLspFactory();
final RecordingVfsFactory vfsFactory = new RecordingVfsFactory();
final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory(lspFactory, vfsFactory);
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final StudioProjectSessionFactory sessionFactory = new StudioProjectSessionFactory(lspFactory, vfsFactory, stateService);
final ProjectReference projectReference = new ProjectReference(
"Example",
"1.0.0",
@ -46,6 +49,24 @@ final class StudioProjectSessionFactoryTest {
assertEquals("Example", lspFactory.capturedContext.projectName());
assertEquals("pbs", lspFactory.capturedContext.languageId());
assertSame(vfsFactory.vfs, lspFactory.capturedVfs);
assertSame(stateService.loadedState, session.projectLocalStudioState());
assertSame(projectReference, stateService.loadedProjectReference);
}
private static final class RecordingProjectLocalStudioStateService extends ProjectLocalStudioStateService {
private ProjectReference loadedProjectReference;
private final ProjectLocalStudioState loadedState = new ProjectLocalStudioState(
ProjectLocalStudioState.CURRENT_VERSION,
new ProjectLocalStudioState.ProjectLocalSetup("/opt/prometeu"),
ProjectLocalStudioState.ShellLayout.defaults(),
new ProjectLocalStudioState.OpenShellState("EDITOR"),
ProjectLocalStudioState.EditorRestorationState.defaults());
@Override
public ProjectLocalStudioState load(final ProjectReference projectReference) {
this.loadedProjectReference = projectReference;
return loadedState;
}
}
private static final class RecordingVfsFactory implements ProjectDocumentVfsFactory {

View File

@ -3,6 +3,8 @@ package p.studio.projectsessions;
import org.junit.jupiter.api.Test;
import p.studio.lsp.messages.LspProjectContext;
import p.studio.lsp.LspService;
import p.studio.projectstate.ProjectLocalStudioState;
import p.studio.projectstate.ProjectLocalStudioStateService;
import p.studio.lsp.dtos.LspSessionStateDTO;
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
import p.studio.lsp.messages.LspAnalyzeDocumentResult;
@ -24,19 +26,60 @@ final class StudioProjectSessionTest {
void closeDelegatesToUnderlyingServicesOnlyOnce() {
final CountingVfsProjectDocument vfs = new CountingVfsProjectDocument();
final CountingPrometeuLspService lsp = new CountingPrometeuLspService(vfs);
final StudioProjectSession session = new StudioProjectSession(projectReference(), lsp, vfs);
final RecordingProjectLocalStudioStateService stateService = new RecordingProjectLocalStudioStateService();
final StudioProjectSession session = new StudioProjectSession(
projectReference(),
lsp,
vfs,
stateService,
ProjectLocalStudioState.defaults());
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,
ProjectLocalStudioState.defaults());
final ProjectLocalStudioState nextState = ProjectLocalStudioState.defaults()
.withProjectLocalSetup(new ProjectLocalStudioState.ProjectLocalSetup("/opt/prometeu/runtime"));
session.replaceProjectLocalStudioState(nextState);
session.close();
assertEquals("/opt/prometeu/runtime", stateService.savedState.projectLocalSetup().prometeuRuntimePath());
}
private ProjectReference projectReference() {
return new ProjectReference("Example", "1.0.0", "pbs", 1, Path.of("/tmp/example"));
}
private static final class RecordingProjectLocalStudioStateService extends ProjectLocalStudioStateService {
private int saveCalls;
private ProjectReference savedProjectReference;
private ProjectLocalStudioState savedState;
@Override
public void save(final ProjectReference projectReference, final ProjectLocalStudioState state) {
saveCalls++;
savedProjectReference = projectReference;
savedState = state;
}
}
private static final class CountingVfsProjectDocument implements VfsProjectDocument {
private int closeCalls;

View File

@ -0,0 +1,83 @@
package p.studio.projectstate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.studio.projects.ProjectReference;
import p.studio.projects.ProjectStudioPaths;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class ProjectLocalStudioStateServiceTest {
@TempDir
Path tempDir;
@Test
void savesAndLoadsSingleFileProjectLocalStudioState() {
final ProjectLocalStudioStateService service = new ProjectLocalStudioStateService();
final ProjectReference project = project("main");
final ProjectLocalStudioState state = new ProjectLocalStudioState(
ProjectLocalStudioState.CURRENT_VERSION,
new ProjectLocalStudioState.ProjectLocalSetup("/opt/prometeu/runtime"),
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()));
service.save(project, state);
assertTrue(Files.isRegularFile(ProjectStudioPaths.statePath(project)));
assertEquals(state, service.load(project));
}
@Test
void malformedStateFallsBackToDefaults() throws Exception {
final ProjectLocalStudioStateService service = new ProjectLocalStudioStateService();
final ProjectReference project = project("main");
Files.createDirectories(ProjectStudioPaths.studioRoot(project));
Files.writeString(ProjectStudioPaths.statePath(project), "{ not valid json");
assertEquals(ProjectLocalStudioState.defaults(), service.load(project));
}
@Test
void unsupportedVersionFallsBackToDefaults() throws Exception {
final ProjectLocalStudioStateService service = new ProjectLocalStudioStateService();
final ProjectReference project = project("main");
Files.createDirectories(ProjectStudioPaths.studioRoot(project));
Files.writeString(
ProjectStudioPaths.statePath(project),
"""
{
"version": 99,
"projectLocalSetup": {
"prometeuRuntimePath": "/opt/legacy/runtime"
}
}
""");
assertEquals(ProjectLocalStudioState.defaults(), service.load(project));
}
private ProjectReference project(final String name) {
final Path projectRoot = tempDir.resolve(name);
return new ProjectReference("Main", "1.0.0", "pbs", 1, projectRoot);
}
}

View File

@ -4,6 +4,7 @@ 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.*;
@ -67,6 +68,24 @@ final class EditorOpenFileSessionTest {
assertTrue(session.activeFile().orElseThrow().readOnly());
}
@Test
void exportsRestorationStateFromOpenTabsAndActiveTab() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, 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,