editor with write capability

This commit is contained in:
bQUARKz 2026-04-04 11:39:04 +01:00
parent fc96e45435
commit a0e19fd143
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
20 changed files with 1082 additions and 410 deletions

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":21,"AGD":22,"DEC":19,"PLN":40,"LSN":34,"CLSN":1}} {"type":"meta","next_id":{"DSC":22,"AGD":23,"DEC":20,"PLN":41,"LSN":34,"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-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-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"}]} {"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"}]}
@ -19,3 +19,4 @@
{"type":"discussion","id":"DSC-0018","status":"done","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":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"discussion/lessons/DSC-0018-studio-project-local-studio-state-under-dot-studio/LSN-0031-project-local-studio-state-and-lifecycle-safe-layout-restoration.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]} {"type":"discussion","id":"DSC-0018","status":"done","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":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"discussion/lessons/DSC-0018-studio-project-local-studio-state-under-dot-studio/LSN-0031-project-local-studio-state-and-lifecycle-safe-layout-restoration.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]}
{"type":"discussion","id":"DSC-0019","status":"done","ticket":"studio-project-local-setup-separate-from-main-studio-state","title":"Separate project-local setup from the main Studio state under .studio","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","project-session","project-state","persistence","dot-studio","setup","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0032","file":"discussion/lessons/DSC-0019-studio-project-local-setup-separate-from-main-studio-state/LSN-0032-separate-project-config-from-session-restoration-state.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]} {"type":"discussion","id":"DSC-0019","status":"done","ticket":"studio-project-local-setup-separate-from-main-studio-state","title":"Separate project-local setup from the main Studio state under .studio","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","project-session","project-state","persistence","dot-studio","setup","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0032","file":"discussion/lessons/DSC-0019-studio-project-local-setup-separate-from-main-studio-state/LSN-0032-separate-project-config-from-session-restoration-state.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]}
{"type":"discussion","id":"DSC-0020","status":"done","ticket":"studio-editor-indentation-policy-and-project-setup","title":"Indentation Policy, Status-Bar Semantics, and Project-Local Editor Setup","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","editor","indentation","tabs","setup","dot-studio","configuration","ux"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0033","file":"discussion/lessons/DSC-0020-studio-editor-indentation-policy-and-project-setup/LSN-0033-setup-owned-indentation-policy-and-project-bootstrap-defaults.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]} {"type":"discussion","id":"DSC-0020","status":"done","ticket":"studio-editor-indentation-policy-and-project-setup","title":"Indentation Policy, Status-Bar Semantics, and Project-Local Editor Setup","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","editor","indentation","tabs","setup","dot-studio","configuration","ux"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0033","file":"discussion/lessons/DSC-0020-studio-editor-indentation-policy-and-project-setup/LSN-0033-setup-owned-indentation-policy-and-project-bootstrap-defaults.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]}
{"type":"discussion","id":"DSC-0021","status":"open","ticket":"studio-frontend-editor-write-and-save-wave","title":"Enable Frontend Editing and Save in the Studio Code Editor","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","editor","frontend","write","save","vfs","lsp","pbs","access-policy"],"agendas":[{"id":"AGD-0022","file":"AGD-0022-studio-frontend-editor-write-and-save-wave.md","status":"accepted","created_at":"2026-04-04","updated_at":"2026-04-04"}],"decisions":[{"id":"DEC-0019","file":"DEC-0019-studio-frontend-editor-write-and-save-wave.md","status":"accepted","created_at":"2026-04-04","updated_at":"2026-04-04","ref_agenda":"AGD-0022"}],"plans":[{"id":"PLN-0040","file":"PLN-0040-studio-frontend-editor-write-and-save-wave.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04","ref_decisions":["DEC-0019"]}],"lessons":[]}

View File

@ -0,0 +1,120 @@
---
id: AGD-0022
discussion: DSC-0021
ticket: studio-frontend-editor-write-and-save-wave
title: Enable Frontend Editing and Save in the Studio Code Editor
status: accepted
created: 2026-04-04
updated: 2026-04-04
owner: studio
tags: [studio, editor, frontend, write, save, vfs, lsp, pbs, access-policy]
---
## Problem
The Studio Code Editor already delivers frontend semantic-read value through `prometeu-lsp`, but frontend documents are still hard `read-only`.
That leaves the FE editing wave unfinished:
- frontend files can be opened, analyzed, highlighted, and navigated;
- but they cannot be edited or saved;
- and the current contract explicitly says that FE editing requires a separate decision.
The next step is to define how FE mutation and save rights should be released without breaking the VFS/LSP/editor ownership split that the repository has already stabilized.
## Context
Domain owner: `studio`.
The current durable constraints are already clear:
1. `prometeu-vfs` owns document classification, access mode, snapshots, and save behavior;
2. `prometeu-lsp` is a semantic consumer of VFS-backed snapshots and must not absorb save or access-policy ownership;
3. Studio UI is a consumer of both boundaries and must not re-derive document rights locally;
4. frontend semantic-read was intentionally released before frontend edit rights;
5. FE edit rights now require a new explicit discussion and decision.
This means the new wave cannot be framed as “let the editor mutate text and figure out persistence later”.
If FE editing is released, it must be released through the same document-runtime owner that already controls access and save semantics.
## Open Questions
1. Should FE editing be released only together with FE save in the same wave?
2. Must FE editable snapshots remain owned by `prometeu-vfs`, exactly like non-frontend editable documents?
3. How should `prometeu-lsp` consume dirty FE snapshots after mutation: on-demand analyze only, background refresh, or both?
4. Are FE edit rights universal for all frontend-scoped supported files, or do they need an additional narrowing rule in this first FE write wave?
5. What explicit safeguards are needed so FE editing does not accidentally turn `prometeu-lsp` into the document owner?
## Options
### Option 1: Allow FE editing in the editor before FE save exists
The editor would accept FE text mutation locally, and LSP could consume that dirty text, but FE save would remain unavailable or deferred.
Tradeoffs:
- appears to unlock FE editing quickly;
- creates a half-owned document state with unclear persistence semantics;
- weakens the current VFS ownership model;
- encourages exactly the “editor mutates first, persistence later” drift the repository has avoided so far.
This option is weak and should be rejected.
### Option 2: Release FE editing and FE save together through `prometeu-vfs`
FE documents become editable only when `prometeu-vfs` can own their writable snapshots, dirty tracking, and save behavior.
The editor consumes that access mode, and `prometeu-lsp` continues to analyze VFS-backed FE snapshots as a consumer.
Tradeoffs:
- preserves the current architecture cleanly;
- avoids split-brain ownership between editor, VFS, and LSP;
- makes FE write behavior coherent on day one;
- requires a broader implementation wave than a UI-only unlock.
This is the strongest option.
### Option 3: Let `prometeu-lsp` become the temporary owner of editable FE session text
LSP would hold the live FE text state because it already consumes FE semantics, and save integration would come later.
Tradeoffs:
- seems convenient because FE semantics already live there;
- collapses semantic ownership into document ownership;
- directly conflicts with the established VFS boundary;
- would make the previous editor/VFS/LSP split editorially and architecturally inconsistent.
This option should be rejected.
## Recommendation
Adopt Option 2.
Recommended direction:
1. FE editing and FE save should be released in the same wave.
2. `prometeu-vfs` should remain the sole owner of FE writable snapshots, access mode, dirty tracking, and persistence.
3. `prometeu-lsp` should continue as a semantic consumer of VFS-backed FE snapshots, including dirty in-memory FE state.
4. Studio UI should remain a policy consumer and must not introduce FE-local save or access heuristics outside the VFS contract.
5. The first FE write wave should explicitly define scope, save semantics, user-visible access changes, and any resulting LSP refresh model.
## Next Step
If this agenda is accepted, convert it into a decision that normatively locks:
- whether FE edit and FE save release together;
- VFS ownership of FE writable snapshots and save behavior;
- LSP consumption rules for dirty FE snapshots;
- and the exact scope of the first FE write wave.
## Resolution
Accepted on 2026-04-04.
The discussion is closed with the following resolution:
1. FE editing and FE save release together in the same wave.
2. `prometeu-vfs` remains the sole owner of FE writable snapshots, access mode, dirty tracking, and persistence.
3. `prometeu-lsp` continues as a semantic consumer of VFS-backed FE snapshots, including dirty in-memory FE state, but no new LSP implementation work is part of the immediate execution wave.
4. The first FE write wave applies to all frontend-scoped supported files rather than introducing an additional narrowing rule.

View File

@ -0,0 +1,125 @@
---
id: DEC-0019
discussion: DSC-0021
agenda: AGD-0022
ticket: studio-frontend-editor-write-and-save-wave
title: Enable Frontend Editing and Save in the Studio Code Editor
status: accepted
created: 2026-04-04
updated: 2026-04-04
owner: studio
tags: [studio, editor, frontend, write, save, vfs, lsp, pbs, access-policy]
---
## Context
Studio already provides frontend semantic-read value through `prometeu-lsp`, while frontend documents remain hard `read-only`.
The repository also already locked the architectural split between document ownership and semantic ownership:
1. `prometeu-vfs` owns document classification, access mode, snapshots, and save behavior;
2. `prometeu-lsp` consumes VFS-backed snapshots for semantic analysis;
3. Studio UI consumes both boundaries and must not re-derive document rights locally.
The previous semantic-read wave explicitly stated that frontend editing requires a separate decision.
This decision closes that gap.
## Decision
Studio SHALL release frontend editing and frontend save together in one explicit write wave.
The wave is normatively defined as follows:
1. Frontend editing MUST NOT be released without frontend save in the same wave.
2. `prometeu-vfs` MUST remain the sole owner of frontend writable snapshots.
3. `prometeu-vfs` MUST remain the sole owner of frontend access mode.
4. `prometeu-vfs` MUST remain the sole owner of frontend dirty tracking.
5. `prometeu-vfs` MUST remain the sole owner of frontend save and save-all behavior.
6. Studio UI MUST consume frontend editability and save rights from `prometeu-vfs` rather than introducing editor-local policy.
7. `prometeu-lsp` MUST continue as a semantic consumer of VFS-backed frontend snapshots, including dirty in-memory frontend state.
8. This decision does NOT authorize a new LSP implementation wave as part of the immediate execution scope.
9. The first frontend write wave MUST apply to all frontend-scoped supported files and MUST NOT introduce an additional narrowing rule in this wave.
10. Releasing frontend editing MUST NOT collapse semantic ownership, document ownership, and persistence ownership into one module.
## Rationale
Frontend mutation without frontend save would create a half-owned editorial state and weaken the document boundary that the repository has already stabilized.
The correct release shape is therefore coherent FE mutation plus coherent FE persistence under the same owner.
Keeping `prometeu-vfs` as the sole owner preserves the existing architecture:
- VFS owns documents and persistence;
- LSP consumes document state for semantics;
- UI renders and interacts with policy.
This decision also avoids using a fake transitional step where the editor mutates frontend text locally while persistence remains undefined.
## Technical Specification
### Frontend Document Ownership
Frontend-scoped supported files MUST move from hard `read-only` to editable only through `prometeu-vfs`.
That means:
1. writable FE in-memory snapshots MUST be created and owned by VFS;
2. FE dirty tracking MUST be owned by VFS;
3. FE save and save-all MUST route through VFS;
4. editor-visible FE access mode MUST be surfaced from VFS.
### Editor Behavior
The Studio editor MUST consume the new FE access policy from VFS exactly as it already does for non-frontend editable documents.
The editor MUST NOT:
1. create a separate FE-only mutation model;
2. implement FE-local persistence outside VFS;
3. infer FE save rights through UI heuristics.
### LSP Consumption Boundary
`prometeu-lsp` remains authorized to consume dirty FE snapshots from VFS.
However, this decision does not authorize a new LSP feature wave as part of immediate execution.
Immediate implementation may rely on the existing semantic-read seam, provided that:
1. no new persistence ownership moves into LSP;
2. no new access-policy ownership moves into LSP;
3. FE write enablement does not depend on introducing a new LSP-owned document model.
### Scope of First FE Write Wave
The first FE write wave applies to all frontend-scoped supported files currently recognized by the VFS/frontend boundary.
This decision does not authorize:
1. a temporary subset of FE file classes;
2. an editor-only FE draft mode;
3. FE edit rights without FE save rights.
## Constraints
1. This decision does not authorize a new LSP implementation scope by itself.
2. This decision does not authorize moving document state ownership into Studio UI.
3. This decision does not authorize moving document state ownership into LSP.
4. This decision does not authorize releasing FE editing through a separate temporary persistence path.
## Propagation Targets
- Specs:
- `docs/specs/studio/5. Code Editor Workspace Specification.md`
- `docs/specs/studio/6. Project Document VFS Specification.md`
- `docs/specs/studio/7. Integrated LSP Semantic Read Phase Specification.md`
- Plans:
- FE VFS write/access/save propagation
- Studio FE editor access-mode and save UX propagation
- Code:
- `prometeu-vfs` FE writable snapshot and save policy
- `prometeu-studio` FE editor write UX and access-state propagation
- Tests:
- FE VFS access-mode and save coverage
- FE editor editable/saveable behavior
## Revision Log
- 2026-04-04: Accepted from `AGD-0022` to release FE editing and save together under VFS ownership, with no new immediate LSP implementation scope.

View File

@ -0,0 +1,175 @@
---
id: PLN-0040
discussion: DSC-0021
decision: DEC-0019
ticket: studio-frontend-editor-write-and-save-wave
title: Implement the frontend editor write and save wave through VFS ownership
status: done
created: 2026-04-04
updated: 2026-04-04
owner: studio
tags: [studio, editor, frontend, write, save, vfs, lsp, pbs, access-policy]
---
## Briefing
Implement the accepted frontend write wave for the Studio Code Editor.
Frontend editing and frontend save must be released together, must remain owned by `prometeu-vfs`, and must be consumed by Studio UI without introducing a new immediate LSP implementation wave.
## Objective
Move frontend-scoped supported files from hard `read-only` to VFS-owned editable/saveable documents while preserving the current boundary:
- `prometeu-vfs` owns document state and persistence;
- `prometeu-lsp` remains a semantic consumer of VFS-backed snapshots;
- Studio UI remains a policy consumer.
## Dependencies
- `DEC-0019` Enable Frontend Editing and Save in the Studio Code Editor
- existing `prometeu-vfs` editable snapshot and save model for non-frontend textual documents
- existing Studio editor consumption of VFS access mode and save behavior
- existing semantic-read seam where `prometeu-lsp` reads VFS-backed frontend snapshots
## Scope
- propagate the new FE write/save contract into Studio and VFS specs
- extend `prometeu-vfs` so frontend-scoped supported files become editable/saveable
- keep FE writable snapshots, dirty tracking, and save ownership inside VFS
- update Studio editor behavior to consume FE editability from VFS
- update FE write/save tests in VFS and Studio
## Non-Goals
- any new LSP feature implementation wave
- per-file FE access policy
- partial FE draft mode without save
- moving persistence logic into Studio UI
- moving persistence logic into `prometeu-lsp`
## Execution Method
### 1. Propagate the accepted contract into specs
Update the Studio specs that still describe FE files as hard `read-only` so they now describe the new FE write/save wave and preserve VFS ownership plus semantic-consumer-only LSP ownership.
Targets:
- `docs/specs/studio/5. Code Editor Workspace Specification.md`
- `docs/specs/studio/6. Project Document VFS Specification.md`
- `docs/specs/studio/7. Integrated LSP Semantic Read Phase Specification.md`
### 2. Change FE access policy in `prometeu-vfs`
Update the filesystem-backed VFS classification so frontend-scoped supported files become `EDITABLE` rather than `READ_ONLY`, while preserving frontend classification itself.
What changes:
- document kind classification for FE files
- VFS acceptance of FE `updateDocument`
- VFS save/save-all behavior for FE snapshots
- FE dirty tracking semantics under the same VFS owner model already used for editable non-frontend documents
Targets:
- `prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemVfsProjectDocument.java`
- any supporting VFS API contracts if required by the implementation
Dependency ordering:
- this step must land before Studio editor UX changes so the UI can consume the new policy rather than invent it.
### 3. Update VFS tests for FE edit/save behavior
Replace tests that assert FE hard `read-only` behavior with tests that assert:
1. FE documents open as frontend documents with `EDITABLE` access mode;
2. FE documents accept `updateDocument`;
3. FE documents save correctly through `saveDocument`;
4. FE documents participate correctly in `saveAllDocuments`.
Targets:
- `prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemVfsProjectDocumentTest.java`
### 4. Propagate FE editability into the Studio editor
Update the Studio editor to consume the new FE access mode from VFS exactly as it already does for editable non-frontend documents.
What changes:
- FE tabs become editable when VFS reports `EDITABLE`
- FE warning surfaces that are specific to hard `read-only` FE behavior must be revised or removed
- editor save/save-all enablement must work for dirty FE buffers through the existing VFS-backed flow
- no FE-local persistence path may be introduced
Targets:
- `prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java`
- any FE-warning or editor-session support classes affected by access-mode transition
Dependency ordering:
- this step depends on VFS access-mode propagation from Step 2.
### 5. Keep LSP integration stable without opening new implementation scope
Verify that the existing editor and semantic-read flow still calls `prometeu-lsp` as a consumer of FE snapshots, but do not introduce a new LSP implementation wave.
What changes:
- only compatibility adjustments that are strictly necessary because FE snapshots are now editable may be made;
- no new semantic features, refresh model, or ownership expansion is authorized here.
Targets:
- only the minimum compatibility surface if compilation or existing tests require it
Dependency ordering:
- this is verification-oriented and should follow the VFS/editor write changes.
### 6. Update Studio tests for FE editable/saveable behavior
Add or revise editor tests so they assert FE files are no longer treated as permanently hard `read-only` once VFS grants FE `EDITABLE` access mode.
Targets:
- Studio editor tests under `prometeu-studio/src/test/java/p/studio/workspaces/editor/...`
- any session/editor tests affected by FE access-mode transition
## Acceptance Criteria
1. Frontend-scoped supported files open through VFS with `EDITABLE` access mode.
2. Frontend-scoped supported files can be updated and saved through VFS-owned snapshots.
3. Studio editor FE tabs become editable only because VFS reports them as editable.
4. FE save and save-all route through the same VFS owner path used by other editable documents.
5. No new persistence or access-policy ownership moves into Studio UI.
6. No new persistence or access-policy ownership moves into `prometeu-lsp`.
7. Specs no longer describe FE files as hard `read-only` for the current wave.
## Tests
- VFS tests for FE access mode, update, save, and save-all
- Studio editor tests for FE editable/saveable behavior
- regression tests that confirm non-frontend editable documents still behave correctly
- compile/test verification that existing LSP consumer seams still pass without opening new LSP feature scope
## Affected Artifacts
- `discussion/workflow/decisions/DEC-0019-studio-frontend-editor-write-and-save-wave.md`
- `docs/specs/studio/5. Code Editor Workspace Specification.md`
- `docs/specs/studio/6. Project Document VFS Specification.md`
- `docs/specs/studio/7. Integrated LSP Semantic Read Phase Specification.md`
- `prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemVfsProjectDocument.java`
- `prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemVfsProjectDocumentTest.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/editor/...`
- Studio editor tests
## Risks
- existing specs and tests currently encode the old hard `read-only` FE contract and will fail until all propagation lands coherently
- FE warning/UI surfaces may assume old read-only semantics in more than one place
- LSP snapshot consumption could reveal hidden assumptions about FE immutability even without opening a new LSP scope
- if implementation order is reversed and Studio is changed before VFS policy, UI code may drift into local heuristics

View File

@ -8,7 +8,7 @@ Active
- `prometeu-studio` - `prometeu-studio`
- the Studio `Code Editor` workspace - the Studio `Code Editor` workspace
- the first controlled editor write wave for supported non-frontend documents - the first controlled editor write wave for supported documents including frontend sources
## Purpose ## Purpose
@ -18,7 +18,7 @@ This specification stabilizes:
- the baseline visual composition of the workspace, - the baseline visual composition of the workspace,
- the `Project Navigator` role and scope, - the `Project Navigator` role and scope,
- the mixed editable and hard `read-only` file-opening model, - the controlled editable file-opening model,
- the responsive tab baseline, - the responsive tab baseline,
- the editor-owned composition surfaces that host save and semantic-read UX, - the editor-owned composition surfaces that host save and semantic-read UX,
- the gutter-based active-structure indicator model for semantic editor scopes, - the gutter-based active-structure indicator model for semantic editor scopes,
@ -42,8 +42,7 @@ If this document conflicts with the global Studio shell specifications, the shel
The `Code Editor` workspace must assume: The `Code Editor` workspace must assume:
- the Studio shell already mounts `Code Editor` as a baseline workspace, - the Studio shell already mounts `Code Editor` as a baseline workspace,
- the current wave allows editing only for the supported non-frontend document classes classified by `prometeu-vfs` as editable, - the current wave allows editing for the supported document classes classified by `prometeu-vfs` as editable, including frontend-scoped supported documents,
- frontend-scoped supported documents remain hard `read-only`,
- all project files remain visible in the editor workspace even when only some are frontend-relevant, - all project files remain visible in the editor workspace even when only some are frontend-relevant,
- `prometeu.json` plus the selected frontend may identify source roots worth tagging, - `prometeu.json` plus the selected frontend may identify source roots worth tagging,
- the integrated frontend semantic-read phase may add diagnostics, symbols, outline-facing structure, definition, and highlight for frontend documents, - the integrated frontend semantic-read phase may add diagnostics, symbols, outline-facing structure, definition, and highlight for frontend documents,
@ -61,7 +60,7 @@ The `Code Editor` workspace is:
- project-aware, - project-aware,
- file-oriented, - file-oriented,
- mixed-mode in this wave, with editable supported non-frontend documents and hard `read-only` frontend documents, - controlled-editable in this wave, with supported documents following the canonical access mode provided by `prometeu-vfs`,
- and not a full semantic IDE surface yet. - and not a full semantic IDE surface yet.
The workspace must help the user: The workspace must help the user:
@ -69,15 +68,14 @@ The workspace must help the user:
- see the full project tree, - see the full project tree,
- identify frontend-relevant source roots visually, - identify frontend-relevant source roots visually,
- open supported files into editor tabs, - open supported files into editor tabs,
- save editable non-frontend documents through editor-local commands, - save editable supported documents through editor-local commands,
- consume frontend semantic-read UX provided through the integrated LSP phase when that phase is active, - consume frontend semantic-read UX provided through the integrated LSP phase when that phase is active,
- understand the active file context, - understand the active file context,
- and understand that frontend editing and broader IDE automation remain outside this wave. - and understand that broader IDE automation remains outside this wave.
The workspace must not pretend to offer: The workspace must not pretend to offer:
- merge behavior, - merge behavior,
- frontend editing,
- completion, - completion,
- rename, code actions, or formatting, - rename, code actions, or formatting,
- or editor-owned semantic inference that bypasses the integrated LSP phase. - or editor-owned semantic inference that bypasses the integrated LSP phase.
@ -163,7 +161,7 @@ Rules:
- Selecting a supported file that is not already open must open it in a new tab. - Selecting a supported file that is not already open must open it in a new tab.
- File opening must resolve document content through `prometeu-vfs`. - File opening must resolve document content through `prometeu-vfs`.
- The editor must maintain opened-file content in memory for the active Studio session only. - The editor must maintain opened-file content in memory for the active Studio session only.
- Frontend-scoped supported documents may coexist in tabs with editable non-frontend documents. - Frontend-scoped supported documents may coexist in tabs with other editable supported documents.
- The tab strip must be responsive rather than fixed to one hardcoded tab count. - The tab strip must be responsive rather than fixed to one hardcoded tab count.
- Overflow tabs must remain accessible through an IntelliJ-style overflow control. - Overflow tabs must remain accessible through an IntelliJ-style overflow control.
- The active tab must remain visible. - The active tab must remain visible.
@ -193,13 +191,11 @@ Rules:
- the workspace must treat `prometeu-vfs` as the canonical source of document access mode for supported files; - the workspace must treat `prometeu-vfs` as the canonical source of document access mode for supported files;
- the workspace must not infer frontend scope from path heuristics, local UI state, or ad hoc extension checks; - the workspace must not infer frontend scope from path heuristics, local UI state, or ad hoc extension checks;
- supported frontend-scoped documents must remain hard `read-only`; - supported frontend-scoped documents may be editable when `prometeu-vfs` classifies them as editable;
- editable scope is limited to the supported non-frontend textual classes exposed by `prometeu-vfs` for this wave; - editable scope is limited to the supported textual classes exposed by `prometeu-vfs` for this wave, including supported frontend sources;
- the workspace must not allow local editorial mutation for hard `read-only` frontend documents;
- the workspace must expose save behavior only through an editor-local command bar containing at least `Save` and `Save All`; - the workspace must expose save behavior only through an editor-local command bar containing at least `Save` and `Save All`;
- the global shell `Save` menu item must not be the save surface for this wave; - the global shell `Save` menu item must not be the save surface for this wave;
- save intent must route through `prometeu-vfs`, which remains the owner of persistence policy; - save intent must route through `prometeu-vfs`, which remains the owner of persistence policy;
- a frontend hard `read-only` tab must show a top warning that the file cannot be edited or saved in this wave;
- the active indentation policy for editable documents MUST come from project-local setup rather than from file-content heuristics; - the active indentation policy for editable documents MUST come from project-local setup rather than from file-content heuristics;
- the status-bar indentation chip MUST display the active configured policy, not inferred file contents; - the status-bar indentation chip MUST display the active configured policy, not inferred file contents;
- pressing `Tab` in an editable document MUST insert spaces according to the active configured indentation width; - pressing `Tab` in an editable document MUST insert spaces according to the active configured indentation width;
@ -224,7 +220,7 @@ Rules:
## Integrated Semantic-Read Boundary ## Integrated Semantic-Read Boundary
- Frontend documents remain hard `read-only` even when semantic-read capabilities are active. - Frontend documents may be editable when `prometeu-vfs` grants editable access mode.
- Diagnostics, document symbols, workspace symbols, outline-facing structure, definition, and frontend highlight must come through the integrated LSP semantic-read phase rather than from editor-local inference. - Diagnostics, document symbols, workspace symbols, outline-facing structure, definition, and frontend highlight must come through the integrated LSP semantic-read phase rather than from editor-local inference.
- Exact anchor positions for semantic scope indicators must come from frontend-owned structural metadata rather than Studio-local text scanning when that metadata is available. - Exact anchor positions for semantic scope indicators must come from frontend-owned structural metadata rather than Studio-local text scanning when that metadata is available.
- Opened frontend documents must be analyzed from the VFS-owned in-memory snapshot exposed through `prometeu-vfs`. - Opened frontend documents must be analyzed from the VFS-owned in-memory snapshot exposed through `prometeu-vfs`.
@ -234,7 +230,7 @@ Rules:
- Frontend semantic presentation resources must remain frontend-owned and must not be replaced by a Studio-owned generic fallback theme. - Frontend semantic presentation resources must remain frontend-owned and must not be replaced by a Studio-owned generic fallback theme.
- When semantic presentation descriptor data or usable frontend resources are absent, the workspace must continue without semantic highlight for that frontend document. - When semantic presentation descriptor data or usable frontend resources are absent, the workspace must continue without semantic highlight for that frontend document.
- The workspace must not surface this condition as a product-facing editor error. - The workspace must not surface this condition as a product-facing editor error.
- The workspace must not treat semantic-read over editorial snapshots as authorization for frontend save, mutation, or build participation. - The workspace must not treat semantic-read over editorial snapshots as authorization for build participation or for bypassing `prometeu-vfs` access policy.
## Inline Hint Rules ## Inline Hint Rules

View File

@ -21,7 +21,7 @@ This specification stabilizes:
- the filesystem-backed first-wave contract, - the filesystem-backed first-wave contract,
- structural tree and document access responsibilities, - structural tree and document access responsibilities,
- canonical frontend scope and access policy ownership, - canonical frontend scope and access policy ownership,
- editorial snapshot and save ownership for editable non-frontend documents, - editorial snapshot and save ownership for editable supported documents including frontend sources,
- the semantic-read consumer boundary used by the integrated LSP phase, - the semantic-read consumer boundary used by the integrated LSP phase,
- the RPC-first public API baseline, - the RPC-first public API baseline,
- and explicit first-wave exclusions such as public event publication and watchers. - and explicit first-wave exclusions such as public event publication and watchers.
@ -140,8 +140,8 @@ Rules:
- `FrontendSpec.allowedExtensions` is the source of truth for frontend scope in this wave; - `FrontendSpec.allowedExtensions` is the source of truth for frontend scope in this wave;
- the VFS document contract must expose a canonical frontend-compatible `typeId` or equivalent scope marker derived from that source of truth; - the VFS document contract must expose a canonical frontend-compatible `typeId` or equivalent scope marker derived from that source of truth;
- consumers must not infer frontend scope from raw dynamic language identifiers, path heuristics, or local UI extension checks; - consumers must not infer frontend scope from raw dynamic language identifiers, path heuristics, or local UI extension checks;
- frontend-scoped supported documents must remain hard `read-only` in this wave; - frontend-scoped supported documents must be editable in this wave when they are supported by the current frontend;
- the initial editable non-frontend set is limited to the currently supported textual classes represented as `text`, `json`, `ndjson`, and `bash`; - the initial editable set is limited to the currently supported textual classes represented as `text`, `json`, `ndjson`, `bash`, and supported frontend source documents resolved from `FrontendSpec.allowedExtensions`;
- and no additional editable class may be inferred by implementation convenience during this wave. - and no additional editable class may be inferred by implementation convenience during this wave.
## Document Access Context ## Document Access Context
@ -161,7 +161,7 @@ Rules:
Rules: Rules:
- opened frontend documents must be exposed to `prometeu-lsp` from the in-memory editorial snapshot held by `prometeu-vfs`; - opened frontend documents must be exposed to `prometeu-lsp` from the in-memory editorial snapshot held by `prometeu-vfs`, including dirty snapshots not yet saved to disk;
- unopened frontend documents may be exposed to `prometeu-lsp` from filesystem-backed state through the same boundary; - unopened frontend documents may be exposed to `prometeu-lsp` from filesystem-backed state through the same boundary;
- `prometeu-lsp` must not bypass `prometeu-vfs` with ad hoc filesystem reads inside Studio UI code; - `prometeu-lsp` must not bypass `prometeu-vfs` with ad hoc filesystem reads inside Studio UI code;
- `prometeu-lsp` must not become the owner of save, persistence, or access policy; - `prometeu-lsp` must not become the owner of save, persistence, or access policy;
@ -219,7 +219,6 @@ Rules:
- merge or conflict handling - merge or conflict handling
- non-project content snapshots - non-project content snapshots
- a generic product-wide filesystem abstraction - a generic product-wide filesystem abstraction
- frontend editing
- treating editorial snapshots as canonical build input - treating editorial snapshots as canonical build input
## Exit Criteria ## Exit Criteria
@ -230,7 +229,7 @@ This specification is complete enough when:
- the project-session lifecycle rule is unambiguous, - the project-session lifecycle rule is unambiguous,
- the structural tree contract is explicitly non-visual, - the structural tree contract is explicitly non-visual,
- frontend scope and access policy ownership are explicit, - frontend scope and access policy ownership are explicit,
- editable non-frontend snapshot and save ownership are explicit, - editable supported-document snapshot and save ownership are explicit,
- the semantic-read consumer boundary with `prometeu-lsp` is explicit, - the semantic-read consumer boundary with `prometeu-lsp` is explicit,
- the RPC-first public API rule is explicit, - the RPC-first public API rule is explicit,
- and deferred public events and watchers are clearly out of scope. - and deferred public events and watchers are clearly out of scope.

View File

@ -9,7 +9,7 @@ Active
- `prometeu-studio` - `prometeu-studio`
- `prometeu-vfs` - `prometeu-vfs`
- `prometeu-lsp` - `prometeu-lsp`
- the frontend read-only semantic phase in the Studio `Code Editor` workspace - the integrated frontend semantic phase in the Studio `Code Editor` workspace
## Purpose ## Purpose
@ -17,7 +17,7 @@ Define the normative Studio contract for the integrated frontend semantic-read p
This specification stabilizes: This specification stabilizes:
- the phase boundary between semantic read and frontend editing, - the phase boundary between semantic ownership and frontend editing ownership,
- the ownership relationship between `prometeu-vfs`, `prometeu-lsp`, and the Studio editor, - the ownership relationship between `prometeu-vfs`, `prometeu-lsp`, and the Studio editor,
- the minimum semantic capability set for frontend documents, - the minimum semantic capability set for frontend documents,
- the dedicated semantic surface used for structural anchors and guide-aware editor structure, - the dedicated semantic surface used for structural anchors and guide-aware editor structure,
@ -37,8 +37,8 @@ If this document conflicts with shell-wide Studio rules, shell rules control she
The integrated semantic-read phase must assume: The integrated semantic-read phase must assume:
- frontend-scoped documents remain hard `read-only`, - frontend-scoped documents may be editable under the controlled write wave exposed by `prometeu-vfs`,
- editable non-frontend documents remain governed by the controlled write wave, - editable supported documents remain governed by the controlled write wave,
- `FrontendSpec.allowedExtensions` remains the source of truth for frontend scope, - `FrontendSpec.allowedExtensions` remains the source of truth for frontend scope,
- `FrontendSpec` is the canonical source of frontend semantic presentation contract data, - `FrontendSpec` is the canonical source of frontend semantic presentation contract data,
- `prometeu-vfs` owns document state, snapshots, persistence, and access policy, - `prometeu-vfs` owns document state, snapshots, persistence, and access policy,
@ -46,13 +46,11 @@ The integrated semantic-read phase must assume:
## Phase Boundary ## Phase Boundary
This phase is a semantic-read phase for frontend documents, not a frontend editing phase. This phase is a semantic phase for frontend documents, not an ownership phase for frontend editing.
Rules: Rules:
- frontend documents must remain hard `read-only` throughout this phase; - no capability in this phase may override `prometeu-vfs` access policy, save policy, or snapshot ownership;
- no capability in this phase may imply frontend save, mutation, or edit-right release;
- the future release of frontend editing requires a separate explicit decision;
- completion, rename, code actions, and formatting remain outside this phase. - completion, rename, code actions, and formatting remain outside this phase.
## Ownership Rules ## Ownership Rules
@ -165,7 +163,6 @@ Rules:
## Non-Goals ## Non-Goals
- frontend editing
- frontend save policy - frontend save policy
- completion - completion
- rename - rename

View File

@ -101,6 +101,11 @@ public enum I18n {
CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"), CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"),
CODE_EDITOR_COMMAND_SAVE("codeEditor.command.save"), CODE_EDITOR_COMMAND_SAVE("codeEditor.command.save"),
CODE_EDITOR_COMMAND_SAVE_ALL("codeEditor.command.saveAll"), CODE_EDITOR_COMMAND_SAVE_ALL("codeEditor.command.saveAll"),
CODE_EDITOR_CLOSE_DIRTY_TITLE("codeEditor.closeDirty.title"),
CODE_EDITOR_CLOSE_DIRTY_MESSAGE("codeEditor.closeDirty.message"),
CODE_EDITOR_CLOSE_DIRTY_SAVE("codeEditor.closeDirty.save"),
CODE_EDITOR_CLOSE_DIRTY_DISCARD("codeEditor.closeDirty.discard"),
CODE_EDITOR_CLOSE_DIRTY_CANCEL("codeEditor.closeDirty.cancel"),
CODE_EDITOR_WARNING_FRONTEND_READ_ONLY("codeEditor.warning.frontendReadOnly"), CODE_EDITOR_WARNING_FRONTEND_READ_ONLY("codeEditor.warning.frontendReadOnly"),
CODE_EDITOR_UNSUPPORTED_FILE_TITLE("codeEditor.unsupportedFile.title"), CODE_EDITOR_UNSUPPORTED_FILE_TITLE("codeEditor.unsupportedFile.title"),
CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE("codeEditor.unsupportedFile.message"), CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE("codeEditor.unsupportedFile.message"),

View File

@ -30,6 +30,24 @@ public final class EditorOpenFileSession {
} }
} }
public void close(final Path path) {
final int index = indexOf(path);
if (index < 0) {
return;
}
final Path normalizedPath = normalize(path);
openFiles.remove(index);
if (openFiles.isEmpty()) {
activePath = null;
return;
}
if (!normalizedPath.equals(activePath)) {
return;
}
final int nextIndex = Math.min(index, openFiles.size() - 1);
activePath = openFiles.get(nextIndex).path();
}
public List<EditorOpenFileBuffer> openFiles() { public List<EditorOpenFileBuffer> openFiles() {
return List.copyOf(openFiles); return List.copyOf(openFiles);
} }
@ -41,6 +59,10 @@ public final class EditorOpenFileSession {
return find(activePath); return find(activePath);
} }
public Optional<EditorOpenFileBuffer> file(final Path path) {
return find(normalize(path));
}
public boolean hasDirtyEditableFiles() { public boolean hasDirtyEditableFiles() {
return openFiles.stream().anyMatch(EditorOpenFileBuffer::saveEnabled); return openFiles.stream().anyMatch(EditorOpenFileBuffer::saveEnabled);
} }

View File

@ -106,7 +106,11 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
} }
} }
}); });
treeView.getSelectionModel().selectedItemProperty().addListener((ignored, previous, current) -> { treeView.setOnMouseClicked(event -> {
if (event.getClickCount() != 2) {
return;
}
final TreeItem<VfsProjectNode> current = treeView.getSelectionModel().getSelectedItem();
if (current == null || current.getValue() == null || current.getValue().directory()) { if (current == null || current.getValue() == null || current.getValue().directory()) {
return; return;
} }

View File

@ -4,7 +4,11 @@ import javafx.application.Platform;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.control.SplitPane; import javafx.scene.control.SplitPane;
import javafx.scene.layout.*; import javafx.scene.layout.*;
@ -32,6 +36,12 @@ import java.util.Objects;
import java.util.function.IntFunction; import java.util.function.IntFunction;
public final class EditorWorkspace extends Workspace { public final class EditorWorkspace extends Workspace {
private static final int CARET_SCROLL_CONTEXT_LINES = 2;
private static final KeyCombination SAVE_SHORTCUT = new KeyCodeCombination(KeyCode.S, KeyCombination.SHORTCUT_DOWN);
private static final KeyCombination SAVE_ALL_SHORTCUT = new KeyCodeCombination(
KeyCode.S,
KeyCombination.SHORTCUT_DOWN,
KeyCombination.SHIFT_DOWN);
private final BorderPane root = new BorderPane(); private final BorderPane root = new BorderPane();
private final CodeArea codeArea = new CodeArea(); private final CodeArea codeArea = new CodeArea();
private final VirtualizedScrollPane<CodeArea> codeScroller = new VirtualizedScrollPane<>(codeArea); private final VirtualizedScrollPane<CodeArea> codeScroller = new VirtualizedScrollPane<>(codeArea);
@ -59,6 +69,8 @@ public final class EditorWorkspace extends Workspace {
List.of()); List.of());
private boolean syncingEditor; private boolean syncingEditor;
private boolean applyingPendingLayoutState; private boolean applyingPendingLayoutState;
private int activeGuideParagraph = -1;
private boolean pendingCaretContextRestore;
private Runnable stateChangedAction = () -> { }; private Runnable stateChangedAction = () -> { };
private ProjectLocalStudioState.EditorLayoutState pendingEditorLayoutState = ProjectLocalStudioState.EditorLayoutState.defaults(); private ProjectLocalStudioState.EditorLayoutState pendingEditorLayoutState = ProjectLocalStudioState.EditorLayoutState.defaults();
private SplitPane contentSplit; private SplitPane contentSplit;
@ -81,12 +93,15 @@ public final class EditorWorkspace extends Workspace {
codeArea.textProperty().addListener((ignored, previous, current) -> syncActiveDocumentToVfs(current)); codeArea.textProperty().addListener((ignored, previous, current) -> syncActiveDocumentToVfs(current));
codeArea.caretPositionProperty().addListener((ignored, previous, current) -> { codeArea.caretPositionProperty().addListener((ignored, previous, current) -> {
final int caretOffset = current == null ? 0 : current.intValue(); final int caretOffset = current == null ? 0 : current.intValue();
updateActiveGuides(caretOffset); updateActiveGuides(caretOffset, codeArea.getCurrentParagraph());
refreshStatusBarCaret(); refreshStatusBarCaret();
}); });
codeArea.estimatedScrollYProperty().addListener((ignored, previous, current) ->
restoreCaretScrollContextIfNeeded(previous, current));
codeArea.getStyleClass().add("editor-workspace-code-area"); codeArea.getStyleClass().add("editor-workspace-code-area");
codeArea.addEventFilter(KeyEvent.KEY_PRESSED, this::guardInlineHintMutation); codeArea.addEventFilter(KeyEvent.KEY_PRESSED, this::guardInlineHintMutation);
codeArea.addEventFilter(KeyEvent.KEY_TYPED, this::guardInlineHintMutation); codeArea.addEventFilter(KeyEvent.KEY_TYPED, this::guardInlineHintMutation);
root.addEventFilter(KeyEvent.KEY_PRESSED, this::handleWorkspaceShortcuts);
inlineHintChangeSubscription = codeArea.plainTextChanges().subscribe(change -> { inlineHintChangeSubscription = codeArea.plainTextChanges().subscribe(change -> {
if (syncingEditor) { if (syncingEditor) {
return; return;
@ -108,6 +123,7 @@ public final class EditorWorkspace extends Workspace {
notifyStateChanged(); notifyStateChanged();
renderSession(); renderSession();
}); });
tabStrip.setTabCloseAction(this::requestCloseFile);
root.setCenter(buildLayout()); root.setCenter(buildLayout());
statusBar.showPlaceholder(presentationRegistry.resolve("text")); statusBar.showPlaceholder(presentationRegistry.resolve("text"));
@ -218,16 +234,13 @@ public final class EditorWorkspace extends Workspace {
codeArea.replaceText(inlineHintProjection.displayText()); codeArea.replaceText(inlineHintProjection.displayText());
codeArea.setStyleSpans(0, inlineHintProjection.displayStyles()); codeArea.setStyleSpans(0, inlineHintProjection.displayStyles());
codeArea.moveTo(0); codeArea.moveTo(0);
codeArea.requestFollowCaret();
} finally { } finally {
syncingEditor = false; syncingEditor = false;
} }
activeGuides = scopeGuideModel.resolveActiveGuides(codeArea.getCaretPosition()); activeGuides = scopeGuideModel.resolveActiveGuides(codeArea.getCaretPosition());
activeGuideParagraph = codeArea.getCurrentParagraph();
refreshParagraphGraphics(); refreshParagraphGraphics();
Platform.runLater(() -> { Platform.runLater(() -> {
codeArea.moveTo(0);
codeArea.showParagraphAtTop(0);
codeArea.requestFollowCaret();
if (fileBuffer.editable()) { if (fileBuffer.editable()) {
codeArea.requestFocus(); codeArea.requestFocus();
} }
@ -261,6 +274,7 @@ public final class EditorWorkspace extends Workspace {
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text"); final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
scopeGuideModel = EditorDocumentScopeGuideModel.empty(); scopeGuideModel = EditorDocumentScopeGuideModel.empty();
activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty(); activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
activeGuideParagraph = -1;
refreshParagraphGraphics(); refreshParagraphGraphics();
applyPresentationStylesheets(presentation); applyPresentationStylesheets(presentation);
syncingEditor = true; syncingEditor = true;
@ -269,15 +283,9 @@ public final class EditorWorkspace extends Workspace {
codeArea.replaceText(""); codeArea.replaceText("");
codeArea.setStyleSpans(0, inlineHintProjection.displayStyles()); codeArea.setStyleSpans(0, inlineHintProjection.displayStyles());
codeArea.moveTo(0); codeArea.moveTo(0);
codeArea.requestFollowCaret();
} finally { } finally {
syncingEditor = false; syncingEditor = false;
} }
Platform.runLater(() -> {
codeArea.moveTo(0);
codeArea.showParagraphAtTop(0);
codeArea.requestFollowCaret();
});
codeArea.setEditable(false); codeArea.setEditable(false);
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation);
saveButton.setDisable(true); saveButton.setDisable(true);
@ -295,11 +303,11 @@ public final class EditorWorkspace extends Workspace {
} }
private void refreshParagraphGraphics() { private void refreshParagraphGraphics() {
codeArea.setParagraphGraphicFactory(paragraphIndex -> EditorDocumentScopeGuideGraphicFactory.create( preserveViewport(() -> codeArea.setParagraphGraphicFactory(paragraphIndex -> EditorDocumentScopeGuideGraphicFactory.create(
lineNumberFactory.apply(paragraphIndex), lineNumberFactory.apply(paragraphIndex),
paragraphIndex, paragraphIndex,
scopeGuideModel, scopeGuideModel,
activeGuides)); activeGuides)));
} }
private VBox buildLayout() { private VBox buildLayout() {
@ -346,7 +354,7 @@ public final class EditorWorkspace extends Workspace {
private void configureCommandBar() { private void configureCommandBar() {
saveButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE)); saveButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE));
saveAllButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE_ALL)); saveAllButton.textProperty().bind(p.studio.Container.i18n().bind(I18n.CODE_EDITOR_COMMAND_SAVE_ALL));
saveButton.getStyleClass().addAll("studio-button", "editor-workspace-command-button"); saveButton.getStyleClass().addAll("studio-button", "studio-button-primary", "editor-workspace-command-button");
saveAllButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "editor-workspace-command-button"); saveAllButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "editor-workspace-command-button");
saveButton.setFocusTraversable(false); saveButton.setFocusTraversable(false);
saveAllButton.setFocusTraversable(false); saveAllButton.setFocusTraversable(false);
@ -378,6 +386,7 @@ public final class EditorWorkspace extends Workspace {
final String sourceContent = inlineHintProjection.stripDecorations(content); final String sourceContent = inlineHintProjection.stripDecorations(content);
final VfsDocumentOpenResult.VfsTextDocument updatedDocument = vfsProjectDocument.updateDocument(activeFile.path(), sourceContent); final VfsDocumentOpenResult.VfsTextDocument updatedDocument = vfsProjectDocument.updateDocument(activeFile.path(), sourceContent);
openFileSession.open(bufferFrom(updatedDocument)); openFileSession.open(bufferFrom(updatedDocument));
pendingCaretContextRestore = true;
refreshEditableHighlighting(updatedDocument); refreshEditableHighlighting(updatedDocument);
statusBar.showDocumentFormatting(updatedDocument.lineSeparator(), indentationSetup.statusLabel()); statusBar.showDocumentFormatting(updatedDocument.lineSeparator(), indentationSetup.statusLabel());
tabStrip.showOpenFiles( tabStrip.showOpenFiles(
@ -393,7 +402,33 @@ public final class EditorWorkspace extends Workspace {
updatedDocument.content(), updatedDocument.content(),
presentation.highlight(updatedDocument.content()), presentation.highlight(updatedDocument.content()),
List.of()); List.of());
codeArea.setStyleSpans(0, inlineHintProjection.displayStyles()); preserveViewport(() -> codeArea.setStyleSpans(0, inlineHintProjection.displayStyles()));
}
private void preserveViewport(final Runnable action) {
final int caretPosition = codeArea.getCaretPosition();
final double scrollX = codeArea.estimatedScrollXProperty().getValue();
final double scrollY = codeArea.estimatedScrollYProperty().getValue();
action.run();
codeArea.moveTo(Math.max(0, Math.min(caretPosition, codeArea.getLength())));
Platform.runLater(() -> {
codeArea.scrollXToPixel(scrollX);
codeArea.scrollYToPixel(scrollY);
});
}
private void restoreCaretScrollContextIfNeeded(final Number previous, final Number current) {
if (!pendingCaretContextRestore) {
return;
}
final double previousValue = previous == null ? Double.NaN : previous.doubleValue();
final double currentValue = current == null ? Double.NaN : current.doubleValue();
if (!Double.isFinite(previousValue) || !Double.isFinite(currentValue) || Double.compare(previousValue, currentValue) == 0) {
return;
}
pendingCaretContextRestore = false;
Platform.runLater(() -> codeArea.showParagraphAtTop(
Math.max(0, codeArea.getCurrentParagraph() - CARET_SCROLL_CONTEXT_LINES)));
} }
private void saveActiveFile() { private void saveActiveFile() {
@ -415,6 +450,67 @@ public final class EditorWorkspace extends Workspace {
renderSession(); renderSession();
} }
private void requestCloseFile(final Path path) {
final var fileBuffer = openFileSession.file(path).orElse(null);
if (fileBuffer == null) {
return;
}
if (fileBuffer.dirty() && !confirmDirtyFileClose(fileBuffer)) {
return;
}
openFileSession.close(path);
notifyStateChanged();
renderSession();
}
private boolean confirmDirtyFileClose(final EditorOpenFileBuffer fileBuffer) {
final var alert = new Alert(Alert.AlertType.CONFIRMATION);
if (root.getScene() != null) {
alert.initOwner(root.getScene().getWindow());
}
final var saveButtonType = new ButtonType(
p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_SAVE),
ButtonBar.ButtonData.YES);
final var discardButtonType = new ButtonType(
p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_DISCARD),
ButtonBar.ButtonData.NO);
final var cancelButtonType = new ButtonType(
p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_CANCEL),
ButtonBar.ButtonData.CANCEL_CLOSE);
alert.setTitle(p.studio.Container.i18n().text(I18n.CODE_EDITOR_CLOSE_DIRTY_TITLE));
alert.setHeaderText(null);
alert.setContentText(p.studio.Container.i18n().format(
I18n.CODE_EDITOR_CLOSE_DIRTY_MESSAGE,
fileBuffer.tabLabel()));
alert.getButtonTypes().setAll(saveButtonType, discardButtonType, cancelButtonType);
final var result = alert.showAndWait().orElse(cancelButtonType);
if (result == saveButtonType) {
vfsProjectDocument.saveDocument(fileBuffer.path());
return true;
}
if (result == discardButtonType) {
vfsProjectDocument.discardDocument(fileBuffer.path());
return true;
}
return false;
}
private void handleWorkspaceShortcuts(final KeyEvent event) {
if (SAVE_ALL_SHORTCUT.match(event)) {
if (!saveAllButton.isDisabled()) {
saveAllFiles();
}
event.consume();
return;
}
if (SAVE_SHORTCUT.match(event)) {
if (!saveButton.isDisabled()) {
saveActiveFile();
}
event.consume();
}
}
private void reloadOpenFilesFromVfs(final Path activePath) { private void reloadOpenFilesFromVfs(final Path activePath) {
final var openPaths = openFileSession.openFiles().stream() final var openPaths = openFileSession.openFiles().stream()
.map(EditorOpenFileBuffer::path) .map(EditorOpenFileBuffer::path)
@ -433,16 +529,7 @@ public final class EditorWorkspace extends Workspace {
private void refreshCommandSurfaces(final EditorOpenFileBuffer fileBuffer) { private void refreshCommandSurfaces(final EditorOpenFileBuffer fileBuffer) {
saveButton.setDisable(!fileBuffer.saveEnabled()); saveButton.setDisable(!fileBuffer.saveEnabled());
saveAllButton.setDisable(!openFileSession.hasDirtyEditableFiles()); saveAllButton.setDisable(!openFileSession.hasDirtyEditableFiles());
final List<EditorWarningStrip.WarningItem> warnings = new ArrayList<>();
if (fileBuffer.frontendDocument() && fileBuffer.readOnly()) {
warnings.add(new EditorWarningStrip.WarningItem(
p.studio.Container.i18n().text(I18n.CODE_EDITOR_WARNING_FRONTEND_READ_ONLY)));
}
if (warnings.isEmpty()) {
warningStrip.clearWarnings(); warningStrip.clearWarnings();
return;
}
warningStrip.showWarnings(warnings);
} }
private EditorOpenFileBuffer bufferFrom(final VfsDocumentOpenResult.VfsTextDocument textDocument) { private EditorOpenFileBuffer bufferFrom(final VfsDocumentOpenResult.VfsTextDocument textDocument) {
@ -483,12 +570,17 @@ public final class EditorWorkspace extends Workspace {
return EditorDocumentScopeGuideModel.from(fileBuffer.content(), analysis.documentSymbols()); return EditorDocumentScopeGuideModel.from(fileBuffer.content(), analysis.documentSymbols());
} }
private void updateActiveGuides(final int caretOffset) { private void updateActiveGuides(final int caretOffset, final int currentParagraph) {
final EditorDocumentScopeGuideModel.ActiveGuides next = scopeGuideModel.resolveActiveGuides(caretOffset); final EditorDocumentScopeGuideModel.ActiveGuides next = scopeGuideModel.resolveActiveGuides(caretOffset);
if (Objects.equals(activeGuides, next)) { if (Objects.equals(activeGuides, next) && activeGuideParagraph == currentParagraph) {
return; return;
} }
activeGuides = next; activeGuides = next;
final boolean paragraphChanged = activeGuideParagraph != currentParagraph;
activeGuideParagraph = currentParagraph;
if (!paragraphChanged) {
return;
}
refreshParagraphGraphics(); refreshParagraphGraphics();
} }

View File

@ -92,6 +92,11 @@ codeEditor.status.indentation=Spaces: 4
codeEditor.status.language=Text codeEditor.status.language=Text
codeEditor.command.save=Save codeEditor.command.save=Save
codeEditor.command.saveAll=Save All codeEditor.command.saveAll=Save All
codeEditor.closeDirty.title=Unsaved changes
codeEditor.closeDirty.message=Save changes to {0} before closing?
codeEditor.closeDirty.save=Save
codeEditor.closeDirty.discard=Discard
codeEditor.closeDirty.cancel=Cancel
codeEditor.warning.frontendReadOnly=This frontend file is read-only in this wave. It cannot be edited or saved yet. codeEditor.warning.frontendReadOnly=This frontend file is read-only in this wave. It cannot be edited or saved yet.
codeEditor.unsupportedFile.title=Unsupported file codeEditor.unsupportedFile.title=Unsupported file
codeEditor.unsupportedFile.message=This file is not supported in this wave: {0} codeEditor.unsupportedFile.message=This file is not supported in this wave: {0}

View File

@ -598,6 +598,29 @@
-fx-text-fill: #d6dde6; -fx-text-fill: #d6dde6;
} }
.editor-workspace-tab-container {
-fx-spacing: 8;
-fx-alignment: center-left;
-fx-padding: 0 8 0 12;
}
.editor-workspace-tab-label {
-fx-alignment: center-left;
-fx-font-size: 12px;
}
.editor-workspace-tab-close-chip {
-fx-alignment: center;
-fx-padding: 0;
-fx-cursor: hand;
}
.editor-workspace-tab-close-icon {
-fx-fill: transparent;
-fx-stroke: #d6dde6;
-fx-stroke-width: 1.15;
}
.editor-workspace-tab-button-active { .editor-workspace-tab-button-active {
-fx-background-color: #16283d; -fx-background-color: #16283d;
-fx-border-color: #8fc4f2 #516579 #516579 #516579; -fx-border-color: #8fc4f2 #516579 #516579 #516579;
@ -612,12 +635,24 @@
-fx-text-fill: #d9dee5; -fx-text-fill: #d9dee5;
} }
.editor-workspace-tab-label.editor-workspace-tab-button-read-only {
-fx-text-fill: #d9dee5;
}
.editor-workspace-tab-button-read-only:hover { .editor-workspace-tab-button-read-only:hover {
-fx-background-color: #2b323d; -fx-background-color: #2b323d;
-fx-border-color: #5b6878; -fx-border-color: #5b6878;
-fx-text-fill: #eff4fa; -fx-text-fill: #eff4fa;
} }
.editor-workspace-tab-label.editor-workspace-tab-button-read-only:hover {
-fx-text-fill: #eff4fa;
}
.editor-workspace-tab-close-chip.editor-workspace-tab-button-read-only .editor-workspace-tab-close-icon {
-fx-stroke: #d9dee5;
}
.editor-workspace-tab-button-read-only.editor-workspace-tab-button-active { .editor-workspace-tab-button-read-only.editor-workspace-tab-button-active {
-fx-background-color: #16283d; -fx-background-color: #16283d;
-fx-border-color: #8fc4f2 #516579 #516579 #516579; -fx-border-color: #8fc4f2 #516579 #516579 #516579;
@ -626,6 +661,11 @@
-fx-font-weight: bold; -fx-font-weight: bold;
} }
.editor-workspace-tab-label.editor-workspace-tab-button-read-only.editor-workspace-tab-button-active {
-fx-text-fill: #ffffff;
-fx-font-weight: bold;
}
.editor-workspace-tab-button-read-only.editor-workspace-tab-button-active:hover { .editor-workspace-tab-button-read-only.editor-workspace-tab-button-active:hover {
-fx-background-color: #1c3148; -fx-background-color: #1c3148;
-fx-border-color: #a7d7ff #5c738b #5c738b #5c738b; -fx-border-color: #a7d7ff #5c738b #5c738b #5c738b;
@ -637,12 +677,24 @@
-fx-text-fill: #e8f6eb; -fx-text-fill: #e8f6eb;
} }
.editor-workspace-tab-label.editor-workspace-tab-button-editable {
-fx-text-fill: #e8f6eb;
}
.editor-workspace-tab-button-editable:hover { .editor-workspace-tab-button-editable:hover {
-fx-background-color: #29412f; -fx-background-color: #29412f;
-fx-border-color: #6f957a; -fx-border-color: #6f957a;
-fx-text-fill: #f4fff5; -fx-text-fill: #f4fff5;
} }
.editor-workspace-tab-label.editor-workspace-tab-button-editable:hover {
-fx-text-fill: #f4fff5;
}
.editor-workspace-tab-close-chip.editor-workspace-tab-button-editable .editor-workspace-tab-close-icon {
-fx-stroke: #e8f6eb;
}
.editor-workspace-tab-button-editable.editor-workspace-tab-button-active { .editor-workspace-tab-button-editable.editor-workspace-tab-button-active {
-fx-background-color: #1d3a2a; -fx-background-color: #1d3a2a;
-fx-border-color: #8ad3a2 #587464 #587464 #587464; -fx-border-color: #8ad3a2 #587464 #587464 #587464;
@ -656,6 +708,7 @@
-fx-border-color: #a5efbd #688676 #688676 #688676; -fx-border-color: #a5efbd #688676 #688676 #688676;
} }
.editor-workspace-tab-overflow { .editor-workspace-tab-overflow {
-fx-background-radius: 0; -fx-background-radius: 0;
-fx-border-radius: 0; -fx-border-radius: 0;
@ -1849,3 +1902,7 @@
-fx-text-fill: #b9cae0; -fx-text-fill: #b9cae0;
-fx-font-size: 12px; -fx-font-size: 12px;
} }
.editor-workspace-tab-label.editor-workspace-tab-button-editable.editor-workspace-tab-button-active {
-fx-text-fill: #ffffff;
-fx-font-weight: bold;
}

View File

@ -13,7 +13,7 @@ final class EditorOpenFileSessionTest {
@Test @Test
void openAddsNewFileAndMarksItActive() { void openAddsNewFileAndMarksItActive() {
final var session = new EditorOpenFileSession(); final var session = new EditorOpenFileSession();
final var file = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "fn main(): void\n"); final var file = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "fn main(): void\n");
session.open(file); session.open(file);
@ -24,8 +24,8 @@ final class EditorOpenFileSessionTest {
@Test @Test
void openDoesNotDuplicateExistingTab() { void openDoesNotDuplicateExistingTab() {
final var session = new EditorOpenFileSession(); final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "a"); final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "b"); final var second = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "b");
session.open(first); session.open(first);
session.open(second); session.open(second);
@ -38,8 +38,8 @@ final class EditorOpenFileSessionTest {
@Test @Test
void activateSwitchesTheActiveTabWithinTheCurrentSession() { void activateSwitchesTheActiveTabWithinTheCurrentSession() {
final var session = new EditorOpenFileSession(); final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "a"); final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("src/other.pbs"), "other.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "b"); final var second = fileBuffer(Path.of("src/other.pbs"), "other.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "b");
session.open(first); session.open(first);
session.open(second); session.open(second);
@ -58,20 +58,54 @@ final class EditorOpenFileSessionTest {
} }
@Test @Test
void frontendReadOnlyFilesDoNotEnableSaveActions() { void frontendEditableFilesParticipateInSaveStateWhenDirty() {
final var session = new EditorOpenFileSession(); final var session = new EditorOpenFileSession();
session.open(fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "fn main(): void")); session.open(fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, true, true, "fn main(): void"));
assertFalse(session.hasSaveableActiveFile()); assertTrue(session.hasSaveableActiveFile());
assertFalse(session.hasDirtyEditableFiles()); assertTrue(session.hasDirtyEditableFiles());
assertTrue(session.activeFile().orElseThrow().frontendDocument()); assertTrue(session.activeFile().orElseThrow().frontendDocument());
assertTrue(session.activeFile().orElseThrow().readOnly()); assertTrue(session.activeFile().orElseThrow().editable());
}
@Test
void closeRemovesInactiveTabWithoutChangingTheActiveTab() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("README.md"), "README.md", VfsDocumentAccessMode.EDITABLE, false, false, "b");
session.open(first);
session.open(second);
session.close(first.path());
assertEquals(1, session.openFiles().size());
assertEquals(second.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
}
@Test
void closePromotesTheNextTabWhenTheActiveTabIsClosed() {
final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("README.md"), "README.md", VfsDocumentAccessMode.EDITABLE, false, false, "b");
final var third = fileBuffer(Path.of("notes.txt"), "notes.txt", VfsDocumentAccessMode.EDITABLE, false, false, "c");
session.open(first);
session.open(second);
session.open(third);
session.activate(second.path());
session.close(second.path());
assertEquals(List.of(
first.path().toAbsolutePath().normalize(),
third.path().toAbsolutePath().normalize()),
session.openFiles().stream().map(EditorOpenFileBuffer::path).toList());
assertEquals(third.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path());
} }
@Test @Test
void exportsRestorationStateFromOpenTabsAndActiveTab() { void exportsRestorationStateFromOpenTabsAndActiveTab() {
final var session = new EditorOpenFileSession(); final var session = new EditorOpenFileSession();
final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.READ_ONLY, false, true, "a"); final var first = fileBuffer(Path.of("src/main.pbs"), "main.pbs", VfsDocumentAccessMode.EDITABLE, false, true, "a");
final var second = fileBuffer(Path.of("README.md"), "README.md", VfsDocumentAccessMode.EDITABLE, false, false, "b"); final var second = fileBuffer(Path.of("README.md"), "README.md", VfsDocumentAccessMode.EDITABLE, false, false, "b");
session.open(first); session.open(first);

View File

@ -33,6 +33,10 @@ public interface VfsProjectDocument extends AutoCloseable {
throw new UnsupportedOperationException("Document save is not supported by this VFS implementation."); throw new UnsupportedOperationException("Document save is not supported by this VFS implementation.");
} }
default VfsDocumentOpenResult.VfsTextDocument discardDocument(final Path path) {
throw new UnsupportedOperationException("Document discard is not supported by this VFS implementation.");
}
default List<VfsDocumentSaveResult> saveAllDocuments() { default List<VfsDocumentSaveResult> saveAllDocuments() {
return List.of(); return List.of();
} }

View File

@ -125,6 +125,13 @@ final class FilesystemVfsProjectDocument implements VfsProjectDocument {
return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.SAVED); return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.SAVED);
} }
@Override
public VfsDocumentOpenResult.VfsTextDocument discardDocument(final Path path) {
final var supportedDocument = requireSupportedDocument(path);
editableSnapshots.remove(supportedDocument.path());
return toVfsTextDocument(supportedDocument, false, accessContextFor(supportedDocument));
}
@Override @Override
public List<VfsDocumentSaveResult> saveAllDocuments() { public List<VfsDocumentSaveResult> saveAllDocuments() {
return editableSnapshots.keySet().stream() return editableSnapshots.keySet().stream()
@ -230,7 +237,7 @@ final class FilesystemVfsProjectDocument implements VfsProjectDocument {
return new DocumentKind(VfsDocumentTypeIds.BASH, false, VfsDocumentAccessMode.EDITABLE); return new DocumentKind(VfsDocumentTypeIds.BASH, false, VfsDocumentAccessMode.EDITABLE);
} }
if (isFrontendSourceDocument(extension)) { if (isFrontendSourceDocument(extension)) {
return new DocumentKind(projectContext.languageId(), true, VfsDocumentAccessMode.READ_ONLY); return new DocumentKind(projectContext.languageId(), true, VfsDocumentAccessMode.EDITABLE);
} }
return new DocumentKind(VfsDocumentTypeIds.TEXT, false, VfsDocumentAccessMode.EDITABLE); return new DocumentKind(VfsDocumentTypeIds.TEXT, false, VfsDocumentAccessMode.EDITABLE);
} }

View File

@ -45,7 +45,7 @@ final class FilesystemVfsProjectDocumentTest {
assertEquals("main.pbs", document.documentName()); assertEquals("main.pbs", document.documentName());
assertEquals("pbs", document.typeId()); assertEquals("pbs", document.typeId());
assertEquals("LF", document.lineSeparator()); assertEquals("LF", document.lineSeparator());
assertEquals(VfsDocumentAccessMode.READ_ONLY, document.accessContext().accessMode()); assertEquals(VfsDocumentAccessMode.EDITABLE, document.accessContext().accessMode());
assertTrue(document.accessContext().frontendDocument()); assertTrue(document.accessContext().frontendDocument());
assertTrue(document.content().contains("fn main()")); assertTrue(document.content().contains("fn main()"));
} }
@ -92,13 +92,23 @@ final class FilesystemVfsProjectDocumentTest {
} }
@Test @Test
void updateDocumentRejectsFrontendDocumentsAsHardReadOnly() throws Exception { void frontendDocumentsUseEditableInMemorySnapshotsUntilSavePersistsThem() throws Exception {
final Path file = tempDir.resolve("main.pbs"); final Path file = tempDir.resolve("main.pbs");
Files.writeString(file, "fn main(): void\n"); Files.writeString(file, "fn main(): void\n");
final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
final var updated = vfs.updateDocument(file, "fn main(): int\n");
assertThrows(IllegalStateException.class, () -> vfs.updateDocument(file, "fn main(): int\n")); assertTrue(updated.dirty());
assertEquals("fn main(): int\n", updated.content());
assertEquals("fn main(): void\n", Files.readString(file));
assertEquals("fn main(): int\n", assertInstanceOf(VfsDocumentOpenResult.VfsTextDocument.class, vfs.openDocument(file)).content());
final VfsDocumentSaveResult saveResult = vfs.saveDocument(file);
assertEquals(VfsDocumentSaveStatus.SAVED, saveResult.status());
assertEquals("fn main(): int\n", Files.readString(file));
assertFalse(assertInstanceOf(VfsDocumentOpenResult.VfsTextDocument.class, vfs.openDocument(file)).dirty());
} }
@Test @Test
@ -123,7 +133,23 @@ final class FilesystemVfsProjectDocumentTest {
} }
@Test @Test
void saveAllDocumentsPersistsOnlyEditableDirtySnapshots() throws Exception { void discardDocumentDropsDirtySnapshotAndRestoresFilesystemState() throws Exception {
final Path file = tempDir.resolve("notes.txt");
Files.writeString(file, "alpha\n");
final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
vfs.updateDocument(file, "beta\n");
final var discarded = vfs.discardDocument(file);
assertFalse(discarded.dirty());
assertEquals("alpha\n", discarded.content());
assertEquals("alpha\n", Files.readString(file));
assertEquals("alpha\n", assertInstanceOf(VfsDocumentOpenResult.VfsTextDocument.class, vfs.openDocument(file)).content());
}
@Test
void saveAllDocumentsPersistsEditableDirtySnapshotsIncludingFrontendDocuments() throws Exception {
final Path editable = tempDir.resolve("notes.txt"); final Path editable = tempDir.resolve("notes.txt");
final Path frontend = tempDir.resolve("main.pbs"); final Path frontend = tempDir.resolve("main.pbs");
Files.writeString(editable, "alpha\n"); Files.writeString(editable, "alpha\n");
@ -131,14 +157,17 @@ final class FilesystemVfsProjectDocumentTest {
final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext());
vfs.updateDocument(editable, "beta\n"); vfs.updateDocument(editable, "beta\n");
vfs.updateDocument(frontend, "fn main(): int\n");
final var results = vfs.saveAllDocuments(); final var results = vfs.saveAllDocuments();
assertEquals(1, results.size()); assertEquals(2, results.size());
assertEquals(VfsDocumentSaveStatus.SAVED, results.get(0).status());
assertEquals(editable.toAbsolutePath().normalize(), results.get(0).path()); assertEquals(editable.toAbsolutePath().normalize(), results.get(0).path());
assertEquals(VfsDocumentSaveStatus.SAVED, results.get(0).status());
assertEquals(frontend.toAbsolutePath().normalize(), results.get(1).path());
assertEquals(VfsDocumentSaveStatus.SAVED, results.get(1).status());
assertEquals("beta\n", Files.readString(editable)); assertEquals("beta\n", Files.readString(editable));
assertEquals("fn main(): void\n", Files.readString(frontend)); assertEquals("fn main(): int\n", Files.readString(frontend));
} }
@Test @Test

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,7 @@ fn frame() -> void
Gfx.clear(new Color(6577)); Gfx.clear(new Color(6577));
if (loading_handle == -1) { if (loading_handle == -1) {
let t = Assets.load(assets.ui.atlas2, 3); let t : (status: int, loading_handle: int) = Assets.load(assets.ui.atlas2, 3);
if (t.status != 0) { if (t.status != 0) {
Log.failure("load failed"); Log.failure("load failed");
} else { } else {
@ -34,14 +34,14 @@ fn frame() -> void
Log.info("state: loading"); Log.info("state: loading");
} }
} else { } else {
let s = Assets.status(loading_handle); let s : int = Assets.status(loading_handle);
if (s == 2) { if (s == 2) {
let commit_status = Assets.commit(loading_handle); let commit_status : int = Assets.commit(loading_handle);
if (commit_status != 0) { if (commit_status != 0) {
Log.failure("commit failed"); Log.failure("commit failed");
} }
} else if (s == 3) { } else if (s == 3) {
let sprite_status = Gfx.set_sprite(3, 10, 150, 150, 0, 0, true, false, false, 1); let sprite_status : int = Gfx.set_sprite(3, 10, 150, 150, 0, 0, true, false, false, 1);
if (sprite_status != 0) { if (sprite_status != 0) {
Log.failure("set_sprite failed"); Log.failure("set_sprite failed");
} }
@ -52,7 +52,7 @@ fn frame() -> void
let touch = Input.touch(); let touch : InputTouch = Input.touch();
if (touch.button().released()) if (touch.button().released())
{ {
@ -77,9 +77,9 @@ fn frame() -> void
Gfx.set_sprite(0, 0, touch.x() - 16, touch.y() + 8, tile_id, 0, true, true, false, 0); Gfx.set_sprite(0, 0, touch.x() - 16, touch.y() + 8, tile_id, 0, true, true, false, 0);
Gfx.set_sprite(0, 1, touch.x() + 16, touch.y() + 8, tile_id, 0, true, false, false, 0); Gfx.set_sprite(0, 1, touch.x() + 16, touch.y() + 8, tile_id, 0, true, false, false, 0);
let a = 10; let a : int = 10;
let b = 15; let b : int = 15;
let total = a + b; let total : int = a + b;
if (Input.pad().a().pressed()) if (Input.pad().a().pressed())
{ {