diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 5050169e..876a17ac 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -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":[]} diff --git a/discussion/workflow/agendas/AGD-0019-studio-project-open-state-under-dot-studio.md b/discussion/workflow/agendas/AGD-0019-studio-project-open-state-under-dot-studio.md new file mode 100644 index 00000000..7a9f800d --- /dev/null +++ b/discussion/workflow/agendas/AGD-0019-studio-project-open-state-under-dot-studio.md @@ -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. diff --git a/discussion/workflow/decisions/DEC-0016-project-local-studio-state-under-dot-studio.md b/discussion/workflow/decisions/DEC-0016-project-local-studio-state-under-dot-studio.md new file mode 100644 index 00000000..df188cdb --- /dev/null +++ b/discussion/workflow/decisions/DEC-0016-project-local-studio-state-under-dot-studio.md @@ -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. diff --git a/discussion/workflow/plans/PLN-0037-project-local-studio-state-store-and-restoration.md b/discussion/workflow/plans/PLN-0037-project-local-studio-state-store-and-restoration.md new file mode 100644 index 00000000..e801c455 --- /dev/null +++ b/discussion/workflow/plans/PLN-0037-project-local-studio-state-store-and-restoration.md @@ -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 workspace’s 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. diff --git a/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md b/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md index e8751292..f6151461 100644 --- a/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md +++ b/docs/specs/studio/1. Studio Shell and Workspace Layout Specification.md @@ -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 diff --git a/docs/specs/studio/5. Code Editor Workspace Specification.md b/docs/specs/studio/5. Code Editor Workspace Specification.md index 45d341c7..dc2f28cf 100644 --- a/docs/specs/studio/5. Code Editor Workspace Specification.md +++ b/docs/specs/studio/5. Code Editor Workspace Specification.md @@ -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 diff --git a/docs/specs/studio/8. Project-Local Studio State Specification.md b/docs/specs/studio/8. Project-Local Studio State Specification.md new file mode 100644 index 00000000..88921435 --- /dev/null +++ b/docs/specs/studio/8. Project-Local Studio State Specification.md @@ -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. diff --git a/docs/specs/studio/README.md b/docs/specs/studio/README.md index 451bba6a..67aa024e 100644 --- a/docs/specs/studio/README.md +++ b/docs/specs/studio/README.md @@ -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 diff --git a/prometeu-studio/src/main/java/p/studio/controls/WorkspaceDockPane.java b/prometeu-studio/src/main/java/p/studio/controls/WorkspaceDockPane.java index c958889d..05ecd35d 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/WorkspaceDockPane.java +++ b/prometeu-studio/src/main/java/p/studio/controls/WorkspaceDockPane.java @@ -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) { + } } diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectStudioPaths.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectStudioPaths.java index 55630aa2..0faf16b4 100644 --- a/prometeu-studio/src/main/java/p/studio/projects/ProjectStudioPaths.java +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectStudioPaths.java @@ -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(); + } } diff --git a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java index efaff034..146ad4de 100644 --- a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java +++ b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java @@ -1,6 +1,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; - prometeuLspService.close(); - vfsProjectDocument.close(); + 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; + } } } diff --git a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java index 8c99a4aa..83f39213 100644 --- a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java +++ b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java @@ -2,6 +2,7 @@ package p.studio.projectsessions; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.LspServiceFactory; +import p.studio.projectstate.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) { diff --git a/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioState.java b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioState.java new file mode 100644 index 00000000..6fbe9cc1 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioState.java @@ -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 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 contentSplitDividers, + List leftColumnDividers, + List rightColumnDividers, + DockPanelState outlinePanel, + DockPanelState helperPanel, + List 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 openTabPaths, String activeTabPath) { + public EditorRestorationState { + openTabPaths = normalizePaths(openTabPaths); + activeTabPath = normalizeText(activeTabPath); + if (activeTabPath != null && !openTabPaths.contains(activeTabPath)) { + final List 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 normalizePaths(final List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + final LinkedHashSet 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 normalizeDividerPositions(final List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + final List 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)); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioStateService.java b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioStateService.java new file mode 100644 index 00000000..de914324 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projectstate/ProjectLocalStudioStateService.java @@ -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); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/window/MainView.java b/prometeu-studio/src/main/java/p/studio/window/MainView.java index 18717af7..42f0057a 100644 --- a/prometeu-studio/src/main/java/p/studio/window/MainView.java +++ b/prometeu-studio/src/main/java/p/studio/window/MainView.java @@ -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 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; + } + } + } diff --git a/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java index 188226e8..b6a82568 100644 --- a/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java +++ b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java @@ -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( diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java index 445111b6..d27c3d01 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -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[] 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]); + } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java index 454da3a7..1ee9e74c 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java @@ -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 find(final Path path) { final int index = indexOf(path); if (index < 0) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java index 1b889de2..df70ba59 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java @@ -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 fileSelectionAction = node -> { }; + private Set 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 expandedPathStrings() { + return captureExpandedPaths(treeView.getRoot()).stream() + .map(Path::toString) + .toList(); + } + + public void restoreExpandedPaths(final List expandedPaths) { + final LinkedHashSet 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 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 mergeExpandedPaths() { + final LinkedHashSet merged = new LinkedHashSet<>(preferredExpandedPaths); + merged.addAll(captureExpandedPaths(treeView.getRoot())); + return Set.copyOf(merged); + } + private Optional selectedPath() { return Optional.ofNullable(treeView.getSelectionModel().getSelectedItem()) .map(TreeItem::getValue) diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index 0881a977..92a1dccf 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -15,6 +15,7 @@ import org.reactfx.Subscription; import p.studio.lsp.LspService; import p.studio.lsp.messages.LspAnalyzeDocumentRequest; import p.studio.lsp.messages.LspAnalyzeDocumentResult; +import p.studio.projectstate.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 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 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) 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[] 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); + } } diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java index 26fdcfb2..fc07ea68 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.LspService; import p.studio.lsp.LspServiceFactory; +import p.studio.projectstate.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 { diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java index 98f9c46a..619bcc05 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java @@ -3,6 +3,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; diff --git a/prometeu-studio/src/test/java/p/studio/projectstate/ProjectLocalStudioStateServiceTest.java b/prometeu-studio/src/test/java/p/studio/projectstate/ProjectLocalStudioStateServiceTest.java new file mode 100644 index 00000000..5c8261ea --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/projectstate/ProjectLocalStudioStateServiceTest.java @@ -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); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java index 33ff411b..1d43eec4 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java @@ -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,