From f9a47bbdbf2e29abe8dc70a08447a2d06b9e5a12 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 31 Mar 2026 16:45:47 +0100 Subject: [PATCH] implements PLN-0020 --- discussion/index.ndjson | 2 +- ...ec-0010-vfs-access-policy-and-save-core.md | 4 +- .../java/p/studio/vfs/ProjectDocumentVfs.java | 22 ++ .../studio/vfs/VfsDocumentAccessContext.java | 20 ++ .../p/studio/vfs/VfsDocumentAccessMode.java | 6 + .../p/studio/vfs/VfsDocumentSaveResult.java | 16 ++ .../p/studio/vfs/VfsDocumentSaveStatus.java | 7 + .../java/p/studio/vfs/VfsDocumentTypeIds.java | 1 + .../java/p/studio/vfs/VfsTextDocument.java | 5 +- .../vfs/FilesystemProjectDocumentVfs.java | 246 ++++++++++++++++-- .../vfs/FilesystemProjectDocumentVfsTest.java | 80 +++++- 11 files changed, 382 insertions(+), 27 deletions(-) create mode 100644 prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessContext.java create mode 100644 prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessMode.java create mode 100644 prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveResult.java create mode 100644 prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveStatus.java diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 2b036e95..2faac05f 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -11,4 +11,4 @@ {"type":"discussion","id":"DSC-0010","status":"done","ticket":"studio-code-editor-workspace-foundations","title":"Establish Code Editor workspace foundations in Studio without LSP","created_at":"2026-03-30","updated_at":"2026-03-31","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0026","file":"discussion/lessons/DSC-0010-studio-code-editor-workspace-foundations/LSN-0026-read-only-editor-foundations-and-semantic-deferral.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]} {"type":"discussion","id":"DSC-0011","status":"done","ticket":"compiler-analyze-compile-build-pipeline-split","title":"Split compiler pipeline into analyze, compile, and build entrypoints","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["compiler","pipeline","artifacts","build","analysis"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"discussion/lessons/DSC-0011-compiler-analyze-compile-build-pipeline-split/LSN-0025-compiler-pipeline-entrypoints-and-result-boundaries.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30"}]} {"type":"discussion","id":"DSC-0012","status":"done","ticket":"studio-editor-document-vfs-boundary","title":"Definir um boundary de VFS documental para tree/view/open files no Code Editor do Studio","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","vfs","filesystem","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"discussion/lessons/DSC-0012-studio-editor-document-vfs-boundary/LSN-0027-project-document-vfs-and-session-owned-editor-boundary.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]} -{"type":"discussion","id":"DSC-0013","status":"open","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[{"id":"AGD-0013","file":"AGD-0013-studio-editor-write-wave-supported-non-frontend-files.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"},{"id":"AGD-0014","file":"AGD-0014-studio-editor-frontend-edit-rights.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0010","file":"DEC-0010-studio-controlled-non-frontend-editor-write-wave.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0013"},{"id":"DEC-0011","file":"DEC-0011-studio-frontend-read-only-minimum-lsp-phase.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0014"}],"plans":[{"id":"PLN-0019","file":"PLN-0019-propagate-dec-0010-into-studio-and-vfs-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0020","file":"PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0021","file":"PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0022","file":"PLN-0022-propagate-dec-0011-into-studio-vfs-and-lsp-specs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0023","file":"PLN-0023-scaffold-flat-packed-prometeu-lsp-api-and-session-seams.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0024","file":"PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0025","file":"PLN-0025-implement-fe-semantic-highlight-consumption.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0013","status":"open","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[{"id":"AGD-0013","file":"AGD-0013-studio-editor-write-wave-supported-non-frontend-files.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"},{"id":"AGD-0014","file":"AGD-0014-studio-editor-frontend-edit-rights.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0010","file":"DEC-0010-studio-controlled-non-frontend-editor-write-wave.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0013"},{"id":"DEC-0011","file":"DEC-0011-studio-frontend-read-only-minimum-lsp-phase.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0014"}],"plans":[{"id":"PLN-0019","file":"PLN-0019-propagate-dec-0010-into-studio-and-vfs-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0020","file":"PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0021","file":"PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0022","file":"PLN-0022-propagate-dec-0011-into-studio-vfs-and-lsp-specs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0023","file":"PLN-0023-scaffold-flat-packed-prometeu-lsp-api-and-session-seams.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0024","file":"PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0025","file":"PLN-0025-implement-fe-semantic-highlight-consumption.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]}],"lessons":[]} diff --git a/discussion/workflow/plans/PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md b/discussion/workflow/plans/PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md index 7066700c..15c129ef 100644 --- a/discussion/workflow/plans/PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md +++ b/discussion/workflow/plans/PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md @@ -2,9 +2,9 @@ id: PLN-0020 ticket: studio-editor-write-wave-supported-non-frontend-files title: Build DEC-0010 VFS access policy and save core -status: review +status: done created: 2026-03-31 -completed: +completed: 2026-03-31 tags: [studio, editor, vfs, write-wave, save, access-policy] --- diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/ProjectDocumentVfs.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/ProjectDocumentVfs.java index c11fd5ad..ec90c63c 100644 --- a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/ProjectDocumentVfs.java +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/ProjectDocumentVfs.java @@ -1,6 +1,8 @@ package p.studio.vfs; import java.nio.file.Path; +import java.util.List; +import java.util.Map; public interface ProjectDocumentVfs extends AutoCloseable { VfsProjectContext projectContext(); @@ -13,6 +15,26 @@ public interface ProjectDocumentVfs extends AutoCloseable { VfsDocumentOpenResult openDocument(Path path); + default VfsDocumentAccessContext accessContext(final Path path) { + throw new UnsupportedOperationException("Document access context is not supported by this VFS implementation."); + } + + default VfsDocumentAccessContext updateAccessContext(final Path path, final Map attributes) { + throw new UnsupportedOperationException("Document access context updates are not supported by this VFS implementation."); + } + + default VfsTextDocument updateDocument(final Path path, final String content) { + throw new UnsupportedOperationException("Document mutation is not supported by this VFS implementation."); + } + + default VfsDocumentSaveResult saveDocument(final Path path) { + throw new UnsupportedOperationException("Document save is not supported by this VFS implementation."); + } + + default List saveAllDocuments() { + return List.of(); + } + @Override default void close() { } diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessContext.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessContext.java new file mode 100644 index 00000000..d110928f --- /dev/null +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessContext.java @@ -0,0 +1,20 @@ +package p.studio.vfs; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; + +public record VfsDocumentAccessContext( + Path path, + String typeId, + boolean frontendDocument, + VfsDocumentAccessMode accessMode, + Map attributes) { + + public VfsDocumentAccessContext { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + Objects.requireNonNull(typeId, "typeId"); + Objects.requireNonNull(accessMode, "accessMode"); + attributes = Map.copyOf(Objects.requireNonNull(attributes, "attributes")); + } +} diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessMode.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessMode.java new file mode 100644 index 00000000..18250cdb --- /dev/null +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentAccessMode.java @@ -0,0 +1,6 @@ +package p.studio.vfs; + +public enum VfsDocumentAccessMode { + READ_ONLY, + EDITABLE +} diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveResult.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveResult.java new file mode 100644 index 00000000..1a57f945 --- /dev/null +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveResult.java @@ -0,0 +1,16 @@ +package p.studio.vfs; + +import java.nio.file.Path; +import java.util.Objects; + +public record VfsDocumentSaveResult( + Path path, + String typeId, + VfsDocumentSaveStatus status) { + + public VfsDocumentSaveResult { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + Objects.requireNonNull(typeId, "typeId"); + Objects.requireNonNull(status, "status"); + } +} diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveStatus.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveStatus.java new file mode 100644 index 00000000..5a8f1e41 --- /dev/null +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentSaveStatus.java @@ -0,0 +1,7 @@ +package p.studio.vfs; + +public enum VfsDocumentSaveStatus { + SAVED, + NO_CHANGES, + READ_ONLY +} diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentTypeIds.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentTypeIds.java index a6cce851..d1ef901b 100644 --- a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentTypeIds.java +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsDocumentTypeIds.java @@ -3,6 +3,7 @@ package p.studio.vfs; public final class VfsDocumentTypeIds { public static final String TEXT = "text"; public static final String JSON = "json"; + public static final String NDJSON = "ndjson"; public static final String BASH = "bash"; private VfsDocumentTypeIds() { diff --git a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java index cf91b267..56a00f73 100644 --- a/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java +++ b/prometeu-vfs/prometeu-vfs-api/src/main/java/p/studio/vfs/VfsTextDocument.java @@ -8,7 +8,9 @@ public record VfsTextDocument( String documentName, String typeId, String content, - String lineSeparator) implements VfsDocumentOpenResult { + String lineSeparator, + boolean dirty, + VfsDocumentAccessContext accessContext) implements VfsDocumentOpenResult { public VfsTextDocument { path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); @@ -16,5 +18,6 @@ public record VfsTextDocument( Objects.requireNonNull(typeId, "typeId"); Objects.requireNonNull(content, "content"); Objects.requireNonNull(lineSeparator, "lineSeparator"); + Objects.requireNonNull(accessContext, "accessContext"); } } diff --git a/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java b/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java index 2357c596..a91b7708 100644 --- a/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java +++ b/prometeu-vfs/prometeu-vfs-v1/src/main/java/p/studio/vfs/FilesystemProjectDocumentVfs.java @@ -12,8 +12,11 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -21,6 +24,8 @@ import java.util.stream.Stream; final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { private final VfsProjectContext projectContext; + private final Map editableSnapshots = new LinkedHashMap<>(); + private final Map> accessContextAttributes = new HashMap<>(); private VfsProjectSnapshot snapshot; FilesystemProjectDocumentVfs(final VfsProjectContext projectContext) { @@ -64,26 +69,90 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { @Override public VfsDocumentOpenResult openDocument(final Path path) { + try { + final var supportedDocument = requireSupportedDocument(path); + return openSupportedDocument(supportedDocument); + } catch (UnsupportedDocumentException unsupportedDocumentException) { + return new VfsUnsupportedDocument( + unsupportedDocumentException.path(), + unsupportedDocumentException.reason()); + } + } + + @Override + public VfsDocumentAccessContext accessContext(final Path path) { + return accessContextFor(requireSupportedDocument(path)); + } + + @Override + public VfsDocumentAccessContext updateAccessContext(final Path path, final Map attributes) { + final var supportedDocument = requireSupportedDocument(path); + accessContextAttributes.put(supportedDocument.path(), Map.copyOf(Objects.requireNonNull(attributes, "attributes"))); + return accessContextFor(supportedDocument); + } + + @Override + public VfsTextDocument updateDocument(final Path path, final String content) { + final var supportedDocument = requireSupportedDocument(path); + if (supportedDocument.accessMode() != VfsDocumentAccessMode.EDITABLE) { + throw new IllegalStateException("Document is hard read-only: " + supportedDocument.path()); + } + final var normalizedPath = supportedDocument.path(); + final var snapshot = editableSnapshots.computeIfAbsent( + normalizedPath, + ignored -> new EditableDocumentSnapshot( + supportedDocument.content(), + supportedDocument.content(), + supportedDocument.lineSeparator())); + snapshot.content = Objects.requireNonNull(content, "content"); + snapshot.lineSeparator = lineSeparator(snapshot.content); + return toVfsTextDocument(supportedDocument.withContent(snapshot.content, snapshot.lineSeparator), snapshot.isDirty(), accessContextFor(supportedDocument)); + } + + @Override + public VfsDocumentSaveResult saveDocument(final Path path) { + final var supportedDocument = requireSupportedDocument(path); + final var accessContext = accessContextFor(supportedDocument); + if (accessContext.accessMode() != VfsDocumentAccessMode.EDITABLE) { + return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.READ_ONLY); + } + final var snapshot = editableSnapshots.computeIfAbsent( + supportedDocument.path(), + ignored -> new EditableDocumentSnapshot( + supportedDocument.content(), + supportedDocument.content(), + supportedDocument.lineSeparator())); + if (!snapshot.isDirty()) { + return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.NO_CHANGES); + } + writeUtf8(supportedDocument.path(), snapshot.content); + snapshot.savedContent = snapshot.content; + snapshot.lineSeparator = lineSeparator(snapshot.content); + return new VfsDocumentSaveResult(supportedDocument.path(), supportedDocument.typeId(), VfsDocumentSaveStatus.SAVED); + } + + @Override + public List saveAllDocuments() { + return editableSnapshots.keySet().stream() + .map(this::saveDocument) + .toList(); + } + + private SupportedDocument requireSupportedDocument(final Path path) { final Path normalizedPath = normalize(path); if (!normalizedPath.startsWith(projectContext.rootPath())) { - return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.OUTSIDE_PROJECT); + throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.OUTSIDE_PROJECT); } if (!Files.exists(normalizedPath)) { - return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.NOT_FOUND); + throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.NOT_FOUND); } if (!Files.isRegularFile(normalizedPath)) { - return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.NOT_A_FILE); - } - - final byte[] bytes; - try { - bytes = Files.readAllBytes(normalizedPath); - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); + throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.NOT_A_FILE); } + final byte[] bytes = readAllBytes(normalizedPath); if (containsNulByte(bytes)) { - return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.BINARY_CONTENT); + throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.BINARY_CONTENT); } final String content; @@ -94,17 +163,51 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { .decode(ByteBuffer.wrap(bytes)) .toString(); } catch (CharacterCodingException codingException) { - return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.INVALID_UTF8); + throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.INVALID_UTF8, codingException); } - return new VfsTextDocument( + final DocumentKind documentKind = documentKind(normalizedPath, content); + return new SupportedDocument( normalizedPath, normalizedPath.getFileName().toString(), - documentTypeId(normalizedPath, content), + documentKind.typeId(), + documentKind.frontendDocument(), + documentKind.accessMode(), content, lineSeparator(content)); } + private VfsDocumentOpenResult openSupportedDocument(final SupportedDocument supportedDocument) { + final var accessContext = accessContextFor(supportedDocument); + if (supportedDocument.accessMode() == VfsDocumentAccessMode.EDITABLE) { + final var snapshot = editableSnapshots.computeIfAbsent( + supportedDocument.path(), + ignored -> new EditableDocumentSnapshot( + supportedDocument.content(), + supportedDocument.content(), + supportedDocument.lineSeparator())); + return toVfsTextDocument( + supportedDocument.withContent(snapshot.content, snapshot.lineSeparator), + snapshot.isDirty(), + accessContext); + } + return toVfsTextDocument(supportedDocument, false, accessContext); + } + + private VfsTextDocument toVfsTextDocument( + final SupportedDocument supportedDocument, + final boolean dirty, + final VfsDocumentAccessContext accessContext) { + return new VfsTextDocument( + supportedDocument.path(), + supportedDocument.documentName(), + supportedDocument.typeId(), + supportedDocument.content(), + supportedDocument.lineSeparator(), + dirty, + accessContext); + } + private VfsProjectSnapshot buildSnapshot() { return new VfsProjectSnapshot( projectContext.rootPath(), @@ -121,18 +224,21 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { .collect(Collectors.toSet()); } - private String documentTypeId(final Path path, final String content) { + private DocumentKind documentKind(final Path path, final String content) { final String extension = extensionOf(path); - if ("json".equals(extension) || "ndjson".equals(extension)) { - return VfsDocumentTypeIds.JSON; + if ("json".equals(extension)) { + return new DocumentKind(VfsDocumentTypeIds.JSON, false, VfsDocumentAccessMode.EDITABLE); + } + if ("ndjson".equals(extension)) { + return new DocumentKind(VfsDocumentTypeIds.NDJSON, false, VfsDocumentAccessMode.EDITABLE); } if (isBashDocument(path, extension, content)) { - return VfsDocumentTypeIds.BASH; + return new DocumentKind(VfsDocumentTypeIds.BASH, false, VfsDocumentAccessMode.EDITABLE); } if (isFrontendSourceDocument(extension)) { - return projectContext.languageId(); + return new DocumentKind(projectContext.languageId(), true, VfsDocumentAccessMode.READ_ONLY); } - return VfsDocumentTypeIds.TEXT; + return new DocumentKind(VfsDocumentTypeIds.TEXT, false, VfsDocumentAccessMode.EDITABLE); } private VfsProjectNode buildNode( @@ -256,4 +362,104 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { private String lineSeparator(final String content) { return content.contains("\r\n") ? "CRLF" : "LF"; } + + private byte[] readAllBytes(final Path path) { + try { + return Files.readAllBytes(path); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private void writeUtf8(final Path path, final String content) { + try { + Files.writeString(path, content, StandardCharsets.UTF_8); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private VfsDocumentAccessContext accessContextFor(final SupportedDocument supportedDocument) { + final var attributes = accessContextAttributes.computeIfAbsent(supportedDocument.path(), ignored -> Map.of()); + return new VfsDocumentAccessContext( + supportedDocument.path(), + supportedDocument.typeId(), + supportedDocument.frontendDocument(), + supportedDocument.accessMode(), + attributes); + } + + private record DocumentKind( + String typeId, + boolean frontendDocument, + VfsDocumentAccessMode accessMode) { + } + + private record SupportedDocument( + Path path, + String documentName, + String typeId, + boolean frontendDocument, + VfsDocumentAccessMode accessMode, + String content, + String lineSeparator) { + + private SupportedDocument withContent(final String updatedContent, final String updatedLineSeparator) { + return new SupportedDocument( + path, + documentName, + typeId, + frontendDocument, + accessMode, + updatedContent, + updatedLineSeparator); + } + } + + private static final class EditableDocumentSnapshot { + private String savedContent; + private String content; + private String lineSeparator; + + private EditableDocumentSnapshot( + final String savedContent, + final String content, + final String lineSeparator) { + this.savedContent = savedContent; + this.content = content; + this.lineSeparator = lineSeparator; + } + + private boolean isDirty() { + return !Objects.equals(savedContent, content); + } + } + + private static final class UnsupportedDocumentException extends IllegalArgumentException { + private final Path path; + private final VfsUnsupportedReason reason; + + private UnsupportedDocumentException(final Path path, final VfsUnsupportedReason reason) { + super(reason.name() + ": " + path); + this.path = path; + this.reason = reason; + } + + private UnsupportedDocumentException( + final Path path, + final VfsUnsupportedReason reason, + final Throwable cause) { + super(reason.name() + ": " + path, cause); + this.path = path; + this.reason = reason; + } + + private Path path() { + return path; + } + + private VfsUnsupportedReason reason() { + return reason; + } + } } diff --git a/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java b/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java index 3657d560..78a5c19b 100644 --- a/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java +++ b/prometeu-vfs/prometeu-vfs-v1/src/test/java/p/studio/vfs/FilesystemProjectDocumentVfsTest.java @@ -5,9 +5,12 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; final class FilesystemProjectDocumentVfsTest { @@ -45,6 +48,8 @@ final class FilesystemProjectDocumentVfsTest { assertEquals("main.pbs", document.documentName()); assertEquals("pbs", document.typeId()); assertEquals("LF", document.lineSeparator()); + assertEquals(VfsDocumentAccessMode.READ_ONLY, document.accessContext().accessMode()); + assertTrue(document.accessContext().frontendDocument()); assertTrue(document.content().contains("fn main()")); } @@ -59,6 +64,7 @@ final class FilesystemProjectDocumentVfsTest { final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); assertEquals(VfsDocumentTypeIds.JSON, document.typeId()); + assertEquals(VfsDocumentAccessMode.EDITABLE, document.accessContext().accessMode()); } @Test @@ -71,7 +77,7 @@ final class FilesystemProjectDocumentVfsTest { final VfsDocumentOpenResult result = vfs.openDocument(file); final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); - assertEquals(VfsDocumentTypeIds.JSON, document.typeId()); + assertEquals(VfsDocumentTypeIds.NDJSON, document.typeId()); } @Test @@ -85,6 +91,74 @@ final class FilesystemProjectDocumentVfsTest { final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); assertEquals(VfsDocumentTypeIds.BASH, document.typeId()); + assertEquals(VfsDocumentAccessMode.EDITABLE, document.accessContext().accessMode()); + } + + @Test + void updateDocumentRejectsFrontendDocumentsAsHardReadOnly() throws Exception { + final Path file = tempDir.resolve("main.pbs"); + Files.writeString(file, "fn main(): void\n"); + + final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); + + assertThrows(IllegalStateException.class, () -> vfs.updateDocument(file, "fn main(): int\n")); + } + + @Test + void editableDocumentsUseInMemorySnapshotsUntilSavePersistsThem() throws Exception { + final Path file = tempDir.resolve("notes.txt"); + Files.writeString(file, "alpha\n"); + + final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); + + final VfsTextDocument updated = vfs.updateDocument(file, "beta\n"); + + assertTrue(updated.dirty()); + assertEquals("beta\n", updated.content()); + assertEquals("alpha\n", Files.readString(file)); + assertEquals("beta\n", assertInstanceOf(VfsTextDocument.class, vfs.openDocument(file)).content()); + + final VfsDocumentSaveResult saveResult = vfs.saveDocument(file); + + assertEquals(VfsDocumentSaveStatus.SAVED, saveResult.status()); + assertEquals("beta\n", Files.readString(file)); + assertFalse(assertInstanceOf(VfsTextDocument.class, vfs.openDocument(file)).dirty()); + } + + @Test + void saveAllDocumentsPersistsOnlyEditableDirtySnapshots() throws Exception { + final Path editable = tempDir.resolve("notes.txt"); + final Path frontend = tempDir.resolve("main.pbs"); + Files.writeString(editable, "alpha\n"); + Files.writeString(frontend, "fn main(): void\n"); + + final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); + vfs.updateDocument(editable, "beta\n"); + + final var results = vfs.saveAllDocuments(); + + assertEquals(1, results.size()); + assertEquals(VfsDocumentSaveStatus.SAVED, results.get(0).status()); + assertEquals(editable.toAbsolutePath().normalize(), results.get(0).path()); + assertEquals("beta\n", Files.readString(editable)); + assertEquals("fn main(): void\n", Files.readString(frontend)); + } + + @Test + void accessContextCanBeReadAndMutatedWithoutChangingCanonicalPolicy() throws Exception { + final Path file = tempDir.resolve("prometeu.json"); + Files.writeString(file, "{\n \"name\": \"Example\"\n}\n"); + + final ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext()); + + final VfsDocumentAccessContext initialContext = vfs.accessContext(file); + final VfsDocumentAccessContext updatedContext = vfs.updateAccessContext(file, Map.of("statusBar", "future-toggle")); + + assertEquals(VfsDocumentAccessMode.EDITABLE, initialContext.accessMode()); + assertEquals(VfsDocumentTypeIds.JSON, initialContext.typeId()); + assertEquals(Map.of("statusBar", "future-toggle"), updatedContext.attributes()); + assertEquals(initialContext.accessMode(), updatedContext.accessMode()); + assertEquals(initialContext.typeId(), updatedContext.typeId()); } @Test @@ -131,7 +205,7 @@ final class FilesystemProjectDocumentVfsTest { assertEquals("main.pbs", srcNode.children().get(1).displayName()); } - private VfsProjectContext projectContext() { - return new VfsProjectContext("Example", "pbs", tempDir); + private p.studio.vfs.VfsProjectContext projectContext() { + return new p.studio.vfs.VfsProjectContext("Example", "pbs", tempDir); } }