implements PLN-0020

This commit is contained in:
bQUARKz 2026-03-31 16:45:47 +01:00
parent 2b7aafaa5c
commit f9a47bbdbf
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
11 changed files with 382 additions and 27 deletions

View File

@ -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-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-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-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":[]}

View File

@ -2,9 +2,9 @@
id: PLN-0020 id: PLN-0020
ticket: studio-editor-write-wave-supported-non-frontend-files ticket: studio-editor-write-wave-supported-non-frontend-files
title: Build DEC-0010 VFS access policy and save core title: Build DEC-0010 VFS access policy and save core
status: review status: done
created: 2026-03-31 created: 2026-03-31
completed: completed: 2026-03-31
tags: [studio, editor, vfs, write-wave, save, access-policy] tags: [studio, editor, vfs, write-wave, save, access-policy]
--- ---

View File

@ -1,6 +1,8 @@
package p.studio.vfs; package p.studio.vfs;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import java.util.Map;
public interface ProjectDocumentVfs extends AutoCloseable { public interface ProjectDocumentVfs extends AutoCloseable {
VfsProjectContext projectContext(); VfsProjectContext projectContext();
@ -13,6 +15,26 @@ public interface ProjectDocumentVfs extends AutoCloseable {
VfsDocumentOpenResult openDocument(Path path); 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<String, String> 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<VfsDocumentSaveResult> saveAllDocuments() {
return List.of();
}
@Override @Override
default void close() { default void close() {
} }

View File

@ -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<String, String> 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"));
}
}

View File

@ -0,0 +1,6 @@
package p.studio.vfs;
public enum VfsDocumentAccessMode {
READ_ONLY,
EDITABLE
}

View File

@ -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");
}
}

View File

@ -0,0 +1,7 @@
package p.studio.vfs;
public enum VfsDocumentSaveStatus {
SAVED,
NO_CHANGES,
READ_ONLY
}

View File

@ -3,6 +3,7 @@ package p.studio.vfs;
public final class VfsDocumentTypeIds { public final class VfsDocumentTypeIds {
public static final String TEXT = "text"; public static final String TEXT = "text";
public static final String JSON = "json"; public static final String JSON = "json";
public static final String NDJSON = "ndjson";
public static final String BASH = "bash"; public static final String BASH = "bash";
private VfsDocumentTypeIds() { private VfsDocumentTypeIds() {

View File

@ -8,7 +8,9 @@ public record VfsTextDocument(
String documentName, String documentName,
String typeId, String typeId,
String content, String content,
String lineSeparator) implements VfsDocumentOpenResult { String lineSeparator,
boolean dirty,
VfsDocumentAccessContext accessContext) implements VfsDocumentOpenResult {
public VfsTextDocument { public VfsTextDocument {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
@ -16,5 +18,6 @@ public record VfsTextDocument(
Objects.requireNonNull(typeId, "typeId"); Objects.requireNonNull(typeId, "typeId");
Objects.requireNonNull(content, "content"); Objects.requireNonNull(content, "content");
Objects.requireNonNull(lineSeparator, "lineSeparator"); Objects.requireNonNull(lineSeparator, "lineSeparator");
Objects.requireNonNull(accessContext, "accessContext");
} }
} }

View File

@ -12,8 +12,11 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -21,6 +24,8 @@ import java.util.stream.Stream;
final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs { final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
private final VfsProjectContext projectContext; private final VfsProjectContext projectContext;
private final Map<Path, EditableDocumentSnapshot> editableSnapshots = new LinkedHashMap<>();
private final Map<Path, Map<String, String>> accessContextAttributes = new HashMap<>();
private VfsProjectSnapshot snapshot; private VfsProjectSnapshot snapshot;
FilesystemProjectDocumentVfs(final VfsProjectContext projectContext) { FilesystemProjectDocumentVfs(final VfsProjectContext projectContext) {
@ -64,26 +69,90 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
@Override @Override
public VfsDocumentOpenResult openDocument(final Path path) { 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<String, String> 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<VfsDocumentSaveResult> saveAllDocuments() {
return editableSnapshots.keySet().stream()
.map(this::saveDocument)
.toList();
}
private SupportedDocument requireSupportedDocument(final Path path) {
final Path normalizedPath = normalize(path); final Path normalizedPath = normalize(path);
if (!normalizedPath.startsWith(projectContext.rootPath())) { if (!normalizedPath.startsWith(projectContext.rootPath())) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.OUTSIDE_PROJECT); throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.OUTSIDE_PROJECT);
} }
if (!Files.exists(normalizedPath)) { if (!Files.exists(normalizedPath)) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.NOT_FOUND); throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.NOT_FOUND);
} }
if (!Files.isRegularFile(normalizedPath)) { if (!Files.isRegularFile(normalizedPath)) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.NOT_A_FILE); throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.NOT_A_FILE);
}
final byte[] bytes;
try {
bytes = Files.readAllBytes(normalizedPath);
} catch (IOException ioException) {
throw new UncheckedIOException(ioException);
} }
final byte[] bytes = readAllBytes(normalizedPath);
if (containsNulByte(bytes)) { if (containsNulByte(bytes)) {
return new VfsUnsupportedDocument(normalizedPath, VfsUnsupportedReason.BINARY_CONTENT); throw new UnsupportedDocumentException(normalizedPath, VfsUnsupportedReason.BINARY_CONTENT);
} }
final String content; final String content;
@ -94,17 +163,51 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
.decode(ByteBuffer.wrap(bytes)) .decode(ByteBuffer.wrap(bytes))
.toString(); .toString();
} catch (CharacterCodingException codingException) { } 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,
normalizedPath.getFileName().toString(), normalizedPath.getFileName().toString(),
documentTypeId(normalizedPath, content), documentKind.typeId(),
documentKind.frontendDocument(),
documentKind.accessMode(),
content, content,
lineSeparator(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() { private VfsProjectSnapshot buildSnapshot() {
return new VfsProjectSnapshot( return new VfsProjectSnapshot(
projectContext.rootPath(), projectContext.rootPath(),
@ -121,18 +224,21 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
.collect(Collectors.toSet()); .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); final String extension = extensionOf(path);
if ("json".equals(extension) || "ndjson".equals(extension)) { if ("json".equals(extension)) {
return VfsDocumentTypeIds.JSON; 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)) { if (isBashDocument(path, extension, content)) {
return VfsDocumentTypeIds.BASH; return new DocumentKind(VfsDocumentTypeIds.BASH, false, VfsDocumentAccessMode.EDITABLE);
} }
if (isFrontendSourceDocument(extension)) { 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( private VfsProjectNode buildNode(
@ -256,4 +362,104 @@ final class FilesystemProjectDocumentVfs implements ProjectDocumentVfs {
private String lineSeparator(final String content) { private String lineSeparator(final String content) {
return content.contains("\r\n") ? "CRLF" : "LF"; 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;
}
}
} }

View File

@ -5,9 +5,12 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
final class FilesystemProjectDocumentVfsTest { final class FilesystemProjectDocumentVfsTest {
@ -45,6 +48,8 @@ final class FilesystemProjectDocumentVfsTest {
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());
assertTrue(document.accessContext().frontendDocument());
assertTrue(document.content().contains("fn main()")); assertTrue(document.content().contains("fn main()"));
} }
@ -59,6 +64,7 @@ final class FilesystemProjectDocumentVfsTest {
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals(VfsDocumentTypeIds.JSON, document.typeId()); assertEquals(VfsDocumentTypeIds.JSON, document.typeId());
assertEquals(VfsDocumentAccessMode.EDITABLE, document.accessContext().accessMode());
} }
@Test @Test
@ -71,7 +77,7 @@ final class FilesystemProjectDocumentVfsTest {
final VfsDocumentOpenResult result = vfs.openDocument(file); final VfsDocumentOpenResult result = vfs.openDocument(file);
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals(VfsDocumentTypeIds.JSON, document.typeId()); assertEquals(VfsDocumentTypeIds.NDJSON, document.typeId());
} }
@Test @Test
@ -85,6 +91,74 @@ final class FilesystemProjectDocumentVfsTest {
final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result); final VfsTextDocument document = assertInstanceOf(VfsTextDocument.class, result);
assertEquals(VfsDocumentTypeIds.BASH, document.typeId()); 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 @Test
@ -131,7 +205,7 @@ final class FilesystemProjectDocumentVfsTest {
assertEquals("main.pbs", srcNode.children().get(1).displayName()); assertEquals("main.pbs", srcNode.children().get(1).displayName());
} }
private VfsProjectContext projectContext() { private p.studio.vfs.VfsProjectContext projectContext() {
return new VfsProjectContext("Example", "pbs", tempDir); return new p.studio.vfs.VfsProjectContext("Example", "pbs", tempDir);
} }
} }