implements PLN-0018

This commit is contained in:
bQUARKz 2026-03-31 08:10:31 +01:00
parent c6b711f3fa
commit 983cbd5fe8
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
11 changed files with 54 additions and 265 deletions

View File

@ -10,4 +10,4 @@
{"type":"discussion","id":"DSC-0009","status":"open","ticket":"studio-debugger-workspace-integration","title":"Integrate ../debugger into Studio as a dedicated workspace","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["studio","debugger","workspace","integration","shell"],"agendas":[{"id":"AGD-0009","file":"AGD-0009-studio-debugger-workspace-integration.md","status":"open","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[],"plans":[],"lessons":[]}
{"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":"open","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":[{"id":"AGD-0012","file":"AGD-0012-studio-editor-document-vfs-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0009","file":"DEC-0009-studio-prometeu-vfs-project-document-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0012"}],"plans":[{"id":"PLN-0015","file":"PLN-0015-propagate-dec-0009-into-studio-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0016","file":"PLN-0016-build-prometeu-vfs-filesystem-backed-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0017","file":"PLN-0017-add-studio-project-session-ownership-for-prometeu-vfs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0018","file":"PLN-0018-migrate-code-editor-to-prometeu-vfs.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0012","status":"open","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":[{"id":"AGD-0012","file":"AGD-0012-studio-editor-document-vfs-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0009","file":"DEC-0009-studio-prometeu-vfs-project-document-boundary.md","status":"in_progress","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0012"}],"plans":[{"id":"PLN-0015","file":"PLN-0015-propagate-dec-0009-into-studio-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0016","file":"PLN-0016-build-prometeu-vfs-filesystem-backed-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0017","file":"PLN-0017-add-studio-project-session-ownership-for-prometeu-vfs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]},{"id":"PLN-0018","file":"PLN-0018-migrate-code-editor-to-prometeu-vfs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0009"]}],"lessons":[]}

View File

@ -2,9 +2,9 @@
id: PLN-0018
ticket: studio-editor-document-vfs-boundary
title: Migrate the Code Editor workspace to consume `prometeu-vfs`
status: review
status: done
created: 2026-03-31
completed:
completed: 2026-03-31
tags:
- studio
- editor

View File

@ -28,7 +28,7 @@ public final class MainView extends BorderPane {
setTop(new StudioShellTopBarControl(menuBar));
host.register(new AssetWorkspace(projectReference));
host.register(new EditorWorkspace(projectReference));
host.register(new EditorWorkspace(projectReference, projectSession.projectDocumentVfs()));
// host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug"));
host.register(new ShipperWorkspace(projectReference));

View File

@ -1,63 +0,0 @@
package p.studio.workspaces.editor;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
public final class EditorFileBufferLoader {
public Optional<EditorOpenFileBuffer> load(final Path path) {
final var normalizedPath = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
if (!Files.isRegularFile(normalizedPath)) {
return Optional.empty();
}
final byte[] bytes;
try {
bytes = Files.readAllBytes(normalizedPath);
} catch (IOException ioException) {
throw new UncheckedIOException(ioException);
}
if (containsNulByte(bytes)) {
return Optional.empty();
}
final String content;
try {
content = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException codingException) {
return Optional.empty();
}
return Optional.of(new EditorOpenFileBuffer(
normalizedPath,
normalizedPath.getFileName().toString(),
content,
lineSeparator(content)));
}
private boolean containsNulByte(final byte[] bytes) {
for (final byte value : bytes) {
if (value == 0) {
return true;
}
}
return false;
}
private String lineSeparator(final String content) {
return content.contains("\r\n") ? "CRLF" : "LF";
}
}

View File

@ -1,6 +1,7 @@
package p.studio.workspaces.editor;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
@ -12,9 +13,10 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.Node;
import p.studio.Container;
import p.studio.utilities.i18n.I18n;
import p.studio.vfs.VfsProjectNode;
import p.studio.vfs.VfsProjectSnapshot;
import java.nio.file.Path;
import java.util.HashSet;
@ -26,12 +28,12 @@ import java.util.function.Consumer;
public final class EditorProjectNavigatorPanel extends BorderPane {
private final Button revealActiveButton = new Button();
private final Button refreshButton = new Button();
private final TreeView<EditorProjectNode> treeView = new TreeView<>();
private final TreeView<VfsProjectNode> treeView = new TreeView<>();
private final Label emptyState = emptyLabel();
private final StackPane content = new StackPane();
private Runnable revealActiveFileAction = () -> { };
private Runnable refreshAction = () -> { };
private Consumer<EditorProjectNode> fileSelectionAction = node -> { };
private Consumer<VfsProjectNode> fileSelectionAction = node -> { };
public EditorProjectNavigatorPanel() {
getStyleClass().addAll("editor-workspace-panel", "editor-workspace-navigator-panel");
@ -82,7 +84,7 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
treeView.setFocusTraversable(false);
treeView.setCellFactory(ignored -> new TreeCell<>() {
@Override
protected void updateItem(final EditorProjectNode item, final boolean empty) {
protected void updateItem(final VfsProjectNode item, final boolean empty) {
super.updateItem(item, empty);
getStyleClass().remove("editor-workspace-tree-cell-tagged");
getStyleClass().remove("editor-workspace-tree-cell-root");
@ -121,11 +123,11 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
revealActiveButton.setDisable(!available);
}
public void setFileSelectionAction(final Consumer<EditorProjectNode> fileSelectionAction) {
public void setFileSelectionAction(final Consumer<VfsProjectNode> fileSelectionAction) {
this.fileSelectionAction = Objects.requireNonNull(fileSelectionAction, "fileSelectionAction");
}
public void setSnapshot(final EditorProjectSnapshot snapshot) {
public void setSnapshot(final VfsProjectSnapshot snapshot) {
final var currentSelection = selectedPath();
final var expandedPaths = captureExpandedPaths(treeView.getRoot());
final var rootItem = buildTreeItem(Objects.requireNonNull(snapshot, "snapshot").rootNode(), expandedPaths, true);
@ -147,11 +149,11 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
});
}
private TreeItem<EditorProjectNode> buildTreeItem(
final EditorProjectNode node,
private TreeItem<VfsProjectNode> buildTreeItem(
final VfsProjectNode node,
final Set<Path> expandedPaths,
final boolean isRoot) {
final TreeItem<EditorProjectNode> item = node.directory()
final TreeItem<VfsProjectNode> item = node.directory()
? new DirectoryTreeItem(node)
: new TreeItem<>(node);
item.setExpanded(isRoot || expandedPaths.contains(node.path()));
@ -161,7 +163,7 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
return item;
}
private Set<Path> captureExpandedPaths(final TreeItem<EditorProjectNode> item) {
private Set<Path> captureExpandedPaths(final TreeItem<VfsProjectNode> item) {
final var expandedPaths = new HashSet<Path>();
if (item == null) {
return expandedPaths;
@ -170,7 +172,7 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
return expandedPaths;
}
private void collectExpandedPaths(final TreeItem<EditorProjectNode> item, final Set<Path> expandedPaths) {
private void collectExpandedPaths(final TreeItem<VfsProjectNode> item, final Set<Path> expandedPaths) {
final var value = item.getValue();
if (value != null && item.isExpanded()) {
expandedPaths.add(value.path());
@ -181,10 +183,10 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
private Optional<Path> selectedPath() {
return Optional.ofNullable(treeView.getSelectionModel().getSelectedItem())
.map(TreeItem::getValue)
.map(EditorProjectNode::path);
.map(VfsProjectNode::path);
}
private void restoreSelection(final TreeItem<EditorProjectNode> rootItem, final Optional<Path> selectedPath) {
private void restoreSelection(final TreeItem<VfsProjectNode> rootItem, final Optional<Path> selectedPath) {
treeView.getSelectionModel().clearSelection();
if (selectedPath.isEmpty()) {
return;
@ -193,7 +195,7 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
.ifPresent(item -> treeView.getSelectionModel().select(item));
}
private Optional<TreeItem<EditorProjectNode>> findTreeItem(final TreeItem<EditorProjectNode> item, final Path path) {
private Optional<TreeItem<VfsProjectNode>> findTreeItem(final TreeItem<VfsProjectNode> item, final Path path) {
if (item == null || item.getValue() == null) {
return Optional.empty();
}
@ -216,15 +218,15 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
emptyState.setManaged(empty);
}
private void expandAncestors(final TreeItem<EditorProjectNode> item) {
TreeItem<EditorProjectNode> current = item;
private void expandAncestors(final TreeItem<VfsProjectNode> item) {
TreeItem<VfsProjectNode> current = item;
while (current != null) {
current.setExpanded(true);
current = current.getParent();
}
}
private Node iconFor(final EditorProjectNode node, final TreeItem<EditorProjectNode> treeItem) {
private Node iconFor(final VfsProjectNode node, final TreeItem<VfsProjectNode> treeItem) {
if (node.directory()) {
if (isBuildTone(node)) {
return EditorWorkspaceIcons.folderBuild();
@ -241,7 +243,7 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
return EditorWorkspaceIcons.file();
}
private String labelFor(final EditorProjectNode node, final TreeItem<EditorProjectNode> treeItem) {
private String labelFor(final VfsProjectNode node, final TreeItem<VfsProjectNode> treeItem) {
if (!isRoot(treeItem)) {
return node.displayName();
}
@ -252,7 +254,7 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
return directoryName + " (" + node.displayName() + ")";
}
private boolean isInsideTaggedSourceRoot(final TreeItem<EditorProjectNode> item) {
private boolean isInsideTaggedSourceRoot(final TreeItem<VfsProjectNode> item) {
var current = item == null ? null : item.getParent();
while (current != null && current.getValue() != null) {
if (current.getValue().taggedSourceRoot()) {
@ -263,11 +265,11 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
return false;
}
private boolean isRoot(final TreeItem<EditorProjectNode> item) {
private boolean isRoot(final TreeItem<VfsProjectNode> item) {
return item != null && item.getParent() == null;
}
private boolean isBuildTone(final EditorProjectNode node) {
private boolean isBuildTone(final VfsProjectNode node) {
return node.directory()
&& ("build".equals(node.displayName()) || "cartridge".equals(node.displayName()));
}
@ -280,8 +282,8 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
return placeholder;
}
private static final class DirectoryTreeItem extends TreeItem<EditorProjectNode> {
private DirectoryTreeItem(final EditorProjectNode node) {
private static final class DirectoryTreeItem extends TreeItem<VfsProjectNode> {
private DirectoryTreeItem(final VfsProjectNode node) {
super(node);
}

View File

@ -1,18 +0,0 @@
package p.studio.workspaces.editor;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
public record EditorProjectNode(
Path path,
String displayName,
boolean directory,
boolean taggedSourceRoot,
List<EditorProjectNode> children) {
public EditorProjectNode {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
displayName = Objects.requireNonNull(displayName, "displayName");
children = List.copyOf(children);
}
}

View File

@ -1,13 +0,0 @@
package p.studio.workspaces.editor;
import java.nio.file.Path;
import java.util.Objects;
public record EditorProjectSnapshot(
Path projectRoot,
EditorProjectNode rootNode) {
public EditorProjectSnapshot {
projectRoot = Objects.requireNonNull(projectRoot, "projectRoot").toAbsolutePath().normalize();
rootNode = Objects.requireNonNull(rootNode, "rootNode");
}
}

View File

@ -1,62 +0,0 @@
package p.studio.workspaces.editor;
import p.studio.compiler.FrontendRegistryService;
import p.studio.projects.ProjectReference;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
public final class EditorProjectSnapshotService {
public EditorProjectSnapshot createSnapshot(final ProjectReference projectReference) {
final var project = Objects.requireNonNull(projectReference, "projectReference");
final var projectRoot = project.rootPath().toAbsolutePath().normalize();
final var taggedRoots = taggedSourceRoots(project);
final var rootNode = buildNode(projectRoot, project.name(), taggedRoots);
return new EditorProjectSnapshot(projectRoot, rootNode);
}
private Set<Path> taggedSourceRoots(final ProjectReference projectReference) {
return FrontendRegistryService.getFrontendSpec(projectReference.languageId())
.stream()
.flatMap(frontendSpec -> frontendSpec.getSourceRoots().stream())
.map(projectReference.rootPath()::resolve)
.map(Path::toAbsolutePath)
.map(Path::normalize)
.collect(java.util.stream.Collectors.toSet());
}
private EditorProjectNode buildNode(
final Path path,
final String displayName,
final Set<Path> taggedRoots) {
if (!Files.isDirectory(path)) {
return new EditorProjectNode(path, displayName, false, taggedRoots.contains(path), List.of());
}
final List<EditorProjectNode> children;
try (Stream<Path> stream = Files.list(path)) {
children = stream
.sorted(nodeComparator())
.map(child -> buildNode(child, child.getFileName().toString(), taggedRoots))
.toList();
} catch (IOException ioException) {
throw new UncheckedIOException(ioException);
}
return new EditorProjectNode(path, displayName, true, taggedRoots.contains(path), children);
}
private Comparator<Path> nodeComparator() {
return Comparator
.comparing((Path path) -> !Files.isDirectory(path))
.thenComparing(path -> path.getFileName().toString(), String.CASE_INSENSITIVE_ORDER);
}
}

View File

@ -12,6 +12,12 @@ import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.Workspace;
import p.studio.workspaces.WorkspaceId;
import p.studio.vfs.ProjectDocumentVfs;
import p.studio.vfs.VfsDocumentOpenResult;
import p.studio.vfs.VfsProjectNode;
import p.studio.vfs.VfsTextDocument;
import java.util.Objects;
public final class EditorWorkspace extends Workspace {
private final BorderPane root = new BorderPane();
@ -21,12 +27,14 @@ public final class EditorWorkspace extends Workspace {
private final EditorHelperPanel helperPanel = new EditorHelperPanel();
private final EditorStatusBar statusBar = new EditorStatusBar();
private final EditorTabStrip tabStrip = new EditorTabStrip();
private final EditorProjectSnapshotService snapshotService = new EditorProjectSnapshotService();
private final EditorFileBufferLoader fileBufferLoader = new EditorFileBufferLoader();
private final ProjectDocumentVfs projectDocumentVfs;
private final EditorOpenFileSession openFileSession = new EditorOpenFileSession();
public EditorWorkspace(final ProjectReference projectReference) {
public EditorWorkspace(
final ProjectReference projectReference,
final ProjectDocumentVfs projectDocumentVfs) {
super(projectReference);
this.projectDocumentVfs = Objects.requireNonNull(projectDocumentVfs, "projectDocumentVfs");
root.getStyleClass().add("editor-workspace");
codeArea.setParagraphGraphicFactory(LineNumberFactory.get(codeArea));
codeArea.setEditable(false);
@ -62,16 +70,24 @@ public final class EditorWorkspace extends Workspace {
public CodeArea codeArea() { return codeArea; }
private void refreshNavigator() {
navigatorPanel.setSnapshot(snapshotService.createSnapshot(projectReference));
navigatorPanel.setSnapshot(projectDocumentVfs.refresh());
}
private void openNode(final EditorProjectNode node) {
fileBufferLoader.load(node.path())
.ifPresentOrElse(this::openFile, () -> showUnsupportedFileModal(node.path()));
private void openNode(final VfsProjectNode node) {
final VfsDocumentOpenResult result = projectDocumentVfs.openDocument(node.path());
if (result instanceof VfsTextDocument textDocument) {
openFile(textDocument);
return;
}
showUnsupportedFileModal(node.path());
}
private void openFile(final EditorOpenFileBuffer fileBuffer) {
openFileSession.open(fileBuffer);
private void openFile(final VfsTextDocument textDocument) {
openFileSession.open(new EditorOpenFileBuffer(
textDocument.path(),
textDocument.documentName(),
textDocument.content(),
textDocument.lineSeparator()));
renderSession();
}

View File

@ -1,37 +0,0 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
final class EditorFileBufferLoaderTest {
@TempDir
Path tempDir;
@Test
void loadReturnsReadOnlyTextBufferForUtf8TextFile() throws Exception {
final var file = tempDir.resolve("main.pbs");
Files.writeString(file, "fn main(): void\n");
final var result = new EditorFileBufferLoader().load(file);
assertTrue(result.isPresent());
assertEquals("main.pbs", result.get().tabLabel());
assertEquals("LF", result.get().lineSeparator());
assertTrue(result.get().content().contains("fn main()"));
}
@Test
void loadRejectsBinaryLikeFile() throws Exception {
final var file = tempDir.resolve("sprite.bin");
Files.write(file, new byte[]{0x01, 0x00, 0x02});
final var result = new EditorFileBufferLoader().load(file);
assertTrue(result.isEmpty());
}
}

View File

@ -1,36 +0,0 @@
package p.studio.workspaces.editor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.studio.projects.ProjectReference;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
final class EditorProjectSnapshotServiceTest {
@TempDir
Path tempDir;
@Test
void snapshotIncludesHiddenFilesOrdersFoldersFirstAndTagsSourceRoots() throws Exception {
Files.createDirectories(tempDir.resolve("src"));
Files.createDirectories(tempDir.resolve("assets"));
Files.writeString(tempDir.resolve(".env"), "TOKEN=1\n");
Files.writeString(tempDir.resolve("README.md"), "# project\n");
final var snapshot = new EditorProjectSnapshotService().createSnapshot(new ProjectReference(
"Example",
"1.0.0",
"pbs",
1,
tempDir));
assertEquals("Example", snapshot.rootNode().displayName());
assertEquals("assets", snapshot.rootNode().children().get(0).displayName());
assertEquals("src", snapshot.rootNode().children().get(1).displayName());
assertEquals(".env", snapshot.rootNode().children().get(2).displayName());
assertTrue(snapshot.rootNode().children().get(1).taggedSourceRoot());
}
}