From c6286f13a5c523519b7ed5ec1a976c0b72a7217c Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Mon, 30 Mar 2026 23:59:19 +0100 Subject: [PATCH] implements PLN-0014 --- discussion/index.ndjson | 2 +- ...tor-snapshot-and-read-only-file-opening.md | 14 +- .../java/p/studio/utilities/i18n/I18n.java | 2 + .../editor/EditorFileBufferLoader.java | 63 ++++++++ .../editor/EditorOpenFileBuffer.java | 17 ++ .../editor/EditorOpenFileSession.java | 67 ++++++++ .../editor/EditorProjectNavigatorPanel.java | 150 ++++++++++++++++-- .../workspaces/editor/EditorProjectNode.java | 18 +++ .../editor/EditorProjectSnapshot.java | 13 ++ .../editor/EditorProjectSnapshotService.java | 62 ++++++++ .../workspaces/editor/EditorStatusBar.java | 73 +++++++-- .../workspaces/editor/EditorTabStrip.java | 115 ++++++++++++-- .../workspaces/editor/EditorWorkspace.java | 76 ++++++++- .../main/resources/i18n/messages.properties | 2 + .../resources/themes/default-prometeu.css | 34 ++++ .../editor/EditorFileBufferLoaderTest.java | 37 +++++ .../editor/EditorOpenFileSessionTest.java | 48 ++++++ .../EditorProjectSnapshotServiceTest.java | 36 +++++ 18 files changed, 779 insertions(+), 50 deletions(-) create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorFileBufferLoader.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNode.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshot.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshotService.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorFileBufferLoaderTest.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorProjectSnapshotServiceTest.java diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 4e5d22bf..bf3215ca 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -8,5 +8,5 @@ {"type":"discussion","id":"DSC-0007","status":"done","ticket":"pbs-learn-to-discussion-lessons-migration","title":"Migrate PBS Learn Documents into Discussion Lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","migration","discussion-framework","lessons","learn-prune"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0018","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0018-pbs-ast-and-parser-contract-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0019","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0019-pbs-name-resolution-and-linking-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0020","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0020-pbs-runtime-values-identity-memory-boundaries-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0021","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0021-pbs-diagnostics-and-conformance-governance-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0022","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0022-pbs-globals-lifecycle-and-published-entrypoint-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]} {"type":"discussion","id":"DSC-0008","status":"done","ticket":"pbs-low-level-asset-manager-surface","title":"PBS Low-Level Asset Manager Surface for Runtime AssetManager","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","runtime","asset-manager","host-abi","stdlib","asset"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0023","file":"discussion/lessons/DSC-0008-pbs-low-level-asset-manager-surface/LSN-0023-lowassets-runtime-aligned-sdk-surface.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]} {"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":"in_progress","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-30","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[{"id":"AGD-0010","file":"AGD-0010-studio-code-editor-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-studio-code-editor-read-only-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30","ref_agenda":"AGD-0010"}],"plans":[{"id":"PLN-0012","file":"PLN-0012-studio-code-editor-spec-propagation.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0013","file":"PLN-0013-editor-workspace-layout-and-passive-surfaces.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0014","file":"PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md","status":"open","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0010","status":"in_progress","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-30","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[{"id":"AGD-0010","file":"AGD-0010-studio-code-editor-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-studio-code-editor-read-only-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30","ref_agenda":"AGD-0010"}],"plans":[{"id":"PLN-0012","file":"PLN-0012-studio-code-editor-spec-propagation.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0013","file":"PLN-0013-editor-workspace-layout-and-passive-surfaces.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]},{"id":"PLN-0014","file":"PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30","ref_decisions":["DEC-0008"]}],"lessons":[]} {"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"}]} diff --git a/discussion/workflow/plans/PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md b/discussion/workflow/plans/PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md index 413d2a2f..4881cc4c 100644 --- a/discussion/workflow/plans/PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md +++ b/discussion/workflow/plans/PLN-0014-project-navigator-snapshot-and-read-only-file-opening.md @@ -2,9 +2,9 @@ id: PLN-0014 ticket: studio-code-editor-workspace-foundations title: Implement the Project Navigator snapshot and read-only file opening flow -status: open +status: done created: 2026-03-30 -completed: +completed: 2026-03-30 tags: - studio - editor @@ -130,11 +130,11 @@ When a navigator selection targets an unsupported file type, show a simple modal ## Acceptance Criteria -- [ ] The editor has a structural navigator snapshot that covers the whole project and tags frontend-relevant roots. -- [ ] The navigator performs initial refresh and supports manual refresh without watcher dependency. -- [ ] Supported files open into read-only tabs with responsive/overflow behavior preserved. -- [ ] Unsupported files trigger a simple modal instead of a partial preview. -- [ ] Opened-file content and visual tab state remain session-local only. +- [x] The editor has a structural navigator snapshot that covers the whole project and tags frontend-relevant roots. +- [x] The navigator performs initial refresh and supports manual refresh without watcher dependency. +- [x] Supported files open into read-only tabs with responsive/overflow behavior preserved. +- [x] Unsupported files trigger a simple modal instead of a partial preview. +- [x] Opened-file content and visual tab state remain session-local only. ## Dependencies diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index d8dc2fa9..dc8fa305 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -87,6 +87,8 @@ public enum I18n { CODE_EDITOR_STATUS_INDENTATION("codeEditor.status.indentation"), CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"), CODE_EDITOR_STATUS_READ_ONLY("codeEditor.status.readOnly"), + CODE_EDITOR_UNSUPPORTED_FILE_TITLE("codeEditor.unsupportedFile.title"), + CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE("codeEditor.unsupportedFile.message"), WORKSPACE_SHIPPER("workspace.shipper"), WORKSPACE_SHIPPER_LOGS("workspace.shipper.logs"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorFileBufferLoader.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorFileBufferLoader.java new file mode 100644 index 00000000..e18ba690 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorFileBufferLoader.java @@ -0,0 +1,63 @@ +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 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"; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java new file mode 100644 index 00000000..4b6d4730 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileBuffer.java @@ -0,0 +1,17 @@ +package p.studio.workspaces.editor; + +import java.nio.file.Path; +import java.util.Objects; + +public record EditorOpenFileBuffer( + Path path, + String tabLabel, + String content, + String lineSeparator) { + public EditorOpenFileBuffer { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + tabLabel = Objects.requireNonNull(tabLabel, "tabLabel"); + content = Objects.requireNonNull(content, "content"); + lineSeparator = Objects.requireNonNull(lineSeparator, "lineSeparator"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java new file mode 100644 index 00000000..6567c89b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOpenFileSession.java @@ -0,0 +1,67 @@ +package p.studio.workspaces.editor; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public final class EditorOpenFileSession { + private final List openFiles = new ArrayList<>(); + private Path activePath; + + public void open(final EditorOpenFileBuffer fileBuffer) { + final var buffer = Objects.requireNonNull(fileBuffer, "fileBuffer"); + final var existingIndex = indexOf(buffer.path()); + if (existingIndex < 0) { + openFiles.add(buffer); + } else { + openFiles.set(existingIndex, buffer); + } + activePath = buffer.path(); + } + + public void activate(final Path path) { + final var normalizedPath = normalize(path); + if (find(normalizedPath).isPresent()) { + activePath = normalizedPath; + } + } + + public List openFiles() { + return List.copyOf(openFiles); + } + + public Optional activeFile() { + if (activePath == null) { + return Optional.empty(); + } + return find(activePath); + } + + public boolean isEmpty() { + return openFiles.isEmpty(); + } + + private Optional find(final Path path) { + final int index = indexOf(path); + if (index < 0) { + return Optional.empty(); + } + return Optional.of(openFiles.get(index)); + } + + private int indexOf(final Path path) { + final var normalizedPath = normalize(path); + for (int index = 0; index < openFiles.size(); index++) { + if (openFiles.get(index).path().equals(normalizedPath)) { + return index; + } + } + return -1; + } + + private Path normalize(final Path path) { + return Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java index ae6bf294..36469a7d 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNavigatorPanel.java @@ -3,21 +3,41 @@ package p.studio.workspaces.editor; import javafx.geometry.Insets; import javafx.scene.control.Button; import javafx.scene.control.Label; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; -import javafx.scene.layout.VBox; +import javafx.scene.layout.StackPane; import p.studio.Container; import p.studio.utilities.i18n.I18n; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + public final class EditorProjectNavigatorPanel extends BorderPane { + private final Button refreshButton = new Button(); + private final TreeView treeView = new TreeView<>(); + private final Label emptyState = emptyLabel(); + private final StackPane content = new StackPane(); + private Runnable refreshAction = () -> { }; + private Consumer fileSelectionAction = node -> { }; public EditorProjectNavigatorPanel() { getStyleClass().addAll("editor-workspace-panel", "editor-workspace-navigator-panel"); setPadding(new Insets(14, 16, 14, 16)); setTop(buildHeader()); - setCenter(buildPlaceholder()); + configureTreeView(); + content.getStyleClass().add("editor-workspace-panel-content"); + content.getChildren().addAll(treeView, emptyState); + setCenter(content); + showEmptyState(true); } private HBox buildHeader() { @@ -25,11 +45,10 @@ public final class EditorProjectNavigatorPanel extends BorderPane { title.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_TITLE)); title.getStyleClass().add("editor-workspace-panel-title"); - final var refreshButton = new Button(); refreshButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_REFRESH)); - refreshButton.setDisable(true); refreshButton.setFocusTraversable(false); refreshButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + refreshButton.setOnAction(event -> refreshAction.run()); final var spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); @@ -39,19 +58,122 @@ public final class EditorProjectNavigatorPanel extends BorderPane { return header; } - private VBox buildPlaceholder() { + private void configureTreeView() { + treeView.getStyleClass().add("editor-workspace-tree"); + treeView.setShowRoot(true); + treeView.setFocusTraversable(false); + treeView.setCellFactory(ignored -> new TreeCell<>() { + @Override + protected void updateItem(final EditorProjectNode item, final boolean empty) { + super.updateItem(item, empty); + getStyleClass().remove("editor-workspace-tree-cell-tagged"); + if (empty || item == null) { + setText(null); + return; + } + setText(item.taggedSourceRoot() ? item.displayName() + " [src]" : item.displayName()); + if (item.taggedSourceRoot()) { + getStyleClass().add("editor-workspace-tree-cell-tagged"); + } + } + }); + treeView.getSelectionModel().selectedItemProperty().addListener((ignored, previous, current) -> { + if (current == null || current.getValue() == null || current.getValue().directory()) { + return; + } + fileSelectionAction.accept(current.getValue()); + }); + } + + public void setRefreshAction(final Runnable refreshAction) { + this.refreshAction = Objects.requireNonNull(refreshAction, "refreshAction"); + } + + public void setFileSelectionAction(final Consumer fileSelectionAction) { + this.fileSelectionAction = Objects.requireNonNull(fileSelectionAction, "fileSelectionAction"); + } + + public void setSnapshot(final EditorProjectSnapshot snapshot) { + final var currentSelection = selectedPath(); + final var expandedPaths = captureExpandedPaths(treeView.getRoot()); + final var rootItem = buildTreeItem(Objects.requireNonNull(snapshot, "snapshot").rootNode(), expandedPaths, true); + treeView.setRoot(rootItem); + restoreSelection(rootItem, currentSelection); + showEmptyState(false); + } + + private TreeItem buildTreeItem( + final EditorProjectNode node, + final Set expandedPaths, + final boolean isRoot) { + final var item = new TreeItem<>(node); + item.setExpanded(isRoot || expandedPaths.contains(node.path())); + node.children().stream() + .map(child -> buildTreeItem(child, expandedPaths, false)) + .forEach(item.getChildren()::add); + return item; + } + + private Set captureExpandedPaths(final TreeItem item) { + final var expandedPaths = new HashSet(); + if (item == null) { + return expandedPaths; + } + collectExpandedPaths(item, expandedPaths); + return expandedPaths; + } + + private void collectExpandedPaths(final TreeItem item, final Set expandedPaths) { + final var value = item.getValue(); + if (value != null && item.isExpanded()) { + expandedPaths.add(value.path()); + } + item.getChildren().forEach(child -> collectExpandedPaths(child, expandedPaths)); + } + + private Optional selectedPath() { + return Optional.ofNullable(treeView.getSelectionModel().getSelectedItem()) + .map(TreeItem::getValue) + .map(EditorProjectNode::path); + } + + private void restoreSelection(final TreeItem rootItem, final Optional selectedPath) { + treeView.getSelectionModel().clearSelection(); + if (selectedPath.isEmpty()) { + return; + } + findTreeItem(rootItem, selectedPath.orElseThrow()) + .ifPresent(item -> treeView.getSelectionModel().select(item)); + } + + private Optional> findTreeItem(final TreeItem item, final Path path) { + if (item == null || item.getValue() == null) { + return Optional.empty(); + } + if (item.getValue().path().equals(path)) { + return Optional.of(item); + } + for (final var child : item.getChildren()) { + final var match = findTreeItem(child, path); + if (match.isPresent()) { + return match; + } + } + return Optional.empty(); + } + + private void showEmptyState(final boolean empty) { + treeView.setVisible(!empty); + treeView.setManaged(!empty); + emptyState.setVisible(empty); + emptyState.setManaged(empty); + } + + private Label emptyLabel() { final var placeholder = new Label(); placeholder.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_PLACEHOLDER)); placeholder.getStyleClass().add("editor-workspace-placeholder"); placeholder.setWrapText(true); - - final var detail = new Label(); - detail.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_DETAIL)); - detail.getStyleClass().add("editor-workspace-placeholder-detail"); - detail.setWrapText(true); - - final var content = new VBox(8, placeholder, detail); - content.getStyleClass().add("editor-workspace-panel-content"); - return content; + return placeholder; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNode.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNode.java new file mode 100644 index 00000000..fcee2a0f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectNode.java @@ -0,0 +1,18 @@ +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 children) { + public EditorProjectNode { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + displayName = Objects.requireNonNull(displayName, "displayName"); + children = List.copyOf(children); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshot.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshot.java new file mode 100644 index 00000000..c7f9a06a --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshot.java @@ -0,0 +1,13 @@ +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"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshotService.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshotService.java new file mode 100644 index 00000000..bd2ec3c4 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorProjectSnapshotService.java @@ -0,0 +1,62 @@ +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 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 taggedRoots) { + if (!Files.isDirectory(path)) { + return new EditorProjectNode(path, displayName, false, taggedRoots.contains(path), List.of()); + } + + final List children; + try (Stream 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 nodeComparator() { + return Comparator + .comparing((Path path) -> !Files.isDirectory(path)) + .thenComparing(path -> path.getFileName().toString(), String.CASE_INSENSITIVE_ORDER); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java index 981893c8..9effa1ce 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorStatusBar.java @@ -9,34 +9,83 @@ import p.studio.Container; import p.studio.utilities.i18n.I18n; public final class EditorStatusBar extends HBox { + private final Label breadcrumb = new Label(); + private final Label position = new Label(); + private final Label lineSeparator = new Label(); + private final Label indentation = new Label(); + private final Label language = new Label(); + private final Label readOnly = new Label(); public EditorStatusBar() { setAlignment(Pos.CENTER_LEFT); setSpacing(12); getStyleClass().add("editor-workspace-status-bar"); - final var breadcrumb = new Label(); - breadcrumb.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_STATUS_BREADCRUMB)); breadcrumb.getStyleClass().add("editor-workspace-status-breadcrumb"); final var spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); + styleChip(position); + styleChip(lineSeparator); + styleChip(indentation); + styleChip(language); + styleChip(readOnly); + getChildren().addAll( breadcrumb, spacer, - statusChip(I18n.CODE_EDITOR_STATUS_POSITION), - statusChip(I18n.CODE_EDITOR_STATUS_LINE_SEPARATOR), - statusChip(I18n.CODE_EDITOR_STATUS_INDENTATION), - statusChip(I18n.CODE_EDITOR_STATUS_LANGUAGE), - statusChip(I18n.CODE_EDITOR_STATUS_READ_ONLY) + position, + lineSeparator, + indentation, + language, + readOnly ); + + showPlaceholder(); } - private Label statusChip(final I18n key) { - final var chip = new Label(); - chip.textProperty().bind(Container.i18n().bind(key)); - chip.getStyleClass().add("editor-workspace-status-chip"); - return chip; + public void showFile(final EditorOpenFileBuffer fileBuffer, final String breadcrumbText) { + setText(breadcrumb, breadcrumbText); + bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION); + setText(lineSeparator, fileBuffer.lineSeparator()); + bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION); + setText(language, extensionText(fileBuffer.path())); + bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY); + } + + public void showPlaceholder() { + bindText(breadcrumb, I18n.CODE_EDITOR_STATUS_BREADCRUMB); + bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION); + bindDefault(lineSeparator, I18n.CODE_EDITOR_STATUS_LINE_SEPARATOR); + bindDefault(indentation, I18n.CODE_EDITOR_STATUS_INDENTATION); + bindDefault(language, I18n.CODE_EDITOR_STATUS_LANGUAGE); + bindDefault(readOnly, I18n.CODE_EDITOR_STATUS_READ_ONLY); + } + + private void bindDefault(final Label label, final I18n key) { + bindText(label, key); + } + + private void bindText(final Label label, final I18n key) { + label.textProperty().unbind(); + label.textProperty().bind(Container.i18n().bind(key)); + } + + private void setText(final Label label, final String text) { + label.textProperty().unbind(); + label.setText(text); + } + + private void styleChip(final Label label) { + if (!label.getStyleClass().contains("editor-workspace-status-chip")) { + label.getStyleClass().add("editor-workspace-status-chip"); + } + } + + private String extensionText(final java.nio.file.Path path) { + final var fileName = path.getFileName().toString(); + final var dot = fileName.lastIndexOf('.'); + return dot >= 0 ? fileName.substring(dot) : fileName; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java index 4bc6529b..db643cb1 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorTabStrip.java @@ -2,33 +2,126 @@ package p.studio.workspaces.editor; import javafx.geometry.Pos; import javafx.scene.control.Button; -import javafx.scene.control.Label; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import p.studio.Container; import p.studio.utilities.i18n.I18n; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + public final class EditorTabStrip extends HBox { + private static final double TAB_WIDTH_HINT = 168.0; + private static final double OVERFLOW_WIDTH_HINT = 110.0; + + private final MenuButton overflowButton = new MenuButton(); + private final Region spacer = new Region(); + private final List openFiles = new ArrayList<>(); + private Consumer tabSelectionAction = path -> { }; + private Path activePath; public EditorTabStrip() { setAlignment(Pos.CENTER_LEFT); setSpacing(10); getStyleClass().add("editor-workspace-tab-strip"); - - final var placeholderTab = new Label(); - placeholderTab.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_PLACEHOLDER)); - placeholderTab.getStyleClass().addAll("editor-workspace-tab", "editor-workspace-tab-active"); - - final var spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - final var overflowButton = new Button(); overflowButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_OVERFLOW)); - overflowButton.setDisable(true); overflowButton.setFocusTraversable(false); - overflowButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + overflowButton.getStyleClass().addAll( + "studio-button", + "studio-button-secondary", + "editor-workspace-tab-overflow"); + widthProperty().addListener((ignored, previous, current) -> rebuild()); + rebuild(); + } - getChildren().addAll(placeholderTab, spacer, overflowButton); + public void setTabSelectionAction(final Consumer tabSelectionAction) { + this.tabSelectionAction = Objects.requireNonNull(tabSelectionAction, "tabSelectionAction"); + } + + public void showOpenFiles(final List files, final Path activePath) { + this.openFiles.clear(); + this.openFiles.addAll(Objects.requireNonNull(files, "files")); + this.activePath = activePath == null ? null : activePath.toAbsolutePath().normalize(); + rebuild(); + } + + private void rebuild() { + getChildren().clear(); + if (openFiles.isEmpty()) { + final var placeholderButton = new Button(); + placeholderButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_PLACEHOLDER)); + placeholderButton.setDisable(true); + placeholderButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + getChildren().addAll(placeholderButton, spacer, overflowButton); + overflowButton.getItems().clear(); + overflowButton.setVisible(false); + overflowButton.setManaged(false); + return; + } + + final int visibleCount = visibleCount(); + final int activeIndex = activeIndex(); + final int startIndex = Math.max(0, Math.min(activeIndex - visibleCount + 1, openFiles.size() - visibleCount)); + final int endIndex = Math.min(openFiles.size(), startIndex + visibleCount); + + for (int index = startIndex; index < endIndex; index++) { + final var fileBuffer = openFiles.get(index); + final var tabButton = new Button(fileBuffer.tabLabel()); + tabButton.setFocusTraversable(false); + tabButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "editor-workspace-tab-button"); + if (fileBuffer.path().equals(activePath)) { + tabButton.getStyleClass().add("editor-workspace-tab-button-active"); + } + tabButton.setOnAction(event -> tabSelectionAction.accept(fileBuffer.path())); + getChildren().add(tabButton); + } + + getChildren().add(spacer); + final boolean hasOverflow = openFiles.size() > visibleCount; + overflowButton.getItems().setAll(buildOverflowItems(startIndex, endIndex)); + overflowButton.setVisible(hasOverflow); + overflowButton.setManaged(hasOverflow); + overflowButton.setDisable(!hasOverflow); + getChildren().add(overflowButton); + } + + private List buildOverflowItems(final int startIndex, final int endIndex) { + final var items = new ArrayList(); + for (int index = 0; index < openFiles.size(); index++) { + if (index >= startIndex && index < endIndex) { + continue; + } + final var fileBuffer = openFiles.get(index); + final var item = new MenuItem(fileBuffer.tabLabel()); + item.setOnAction(event -> tabSelectionAction.accept(fileBuffer.path())); + items.add(item); + } + return items; + } + + private int activeIndex() { + if (activePath == null) { + return Math.max(0, openFiles.size() - 1); + } + for (int index = 0; index < openFiles.size(); index++) { + if (openFiles.get(index).path().equals(activePath)) { + return index; + } + } + return Math.max(0, openFiles.size() - 1); + } + + private int visibleCount() { + final double availableWidth = getWidth() > 0 ? getWidth() : 760; + final double usableWidth = Math.max(availableWidth - OVERFLOW_WIDTH_HINT, TAB_WIDTH_HINT); + return Math.max(1, (int) Math.floor(usableWidth / TAB_WIDTH_HINT)); } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index a348ecfc..41d5ba39 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -1,5 +1,6 @@ package p.studio.workspaces.editor; +import javafx.scene.control.Alert; import javafx.scene.Node; import javafx.scene.control.SplitPane; import javafx.scene.layout.BorderPane; @@ -20,6 +21,9 @@ 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 EditorOpenFileSession openFileSession = new EditorOpenFileSession(); public EditorWorkspace(final ProjectReference projectReference) { super(projectReference); @@ -28,12 +32,16 @@ public final class EditorWorkspace extends Workspace { codeArea.setEditable(false); codeArea.setWrapText(false); codeArea.getStyleClass().add("editor-workspace-code-area"); - codeArea.replaceText(""" - // Read-only first wave - // Open-file wiring lands in the next plan. - """); + showEditorPlaceholder(); + navigatorPanel.setRefreshAction(this::refreshNavigator); + navigatorPanel.setFileSelectionAction(this::openNode); + tabStrip.setTabSelectionAction(path -> { + openFileSession.activate(path); + renderSession(); + }); root.setCenter(buildLayout()); + statusBar.showPlaceholder(); } @Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; } @@ -42,7 +50,7 @@ public final class EditorWorkspace extends Workspace { @Override public void load() { - + refreshNavigator(); } @Override @@ -52,6 +60,64 @@ public final class EditorWorkspace extends Workspace { public CodeArea codeArea() { return codeArea; } + private void refreshNavigator() { + navigatorPanel.setSnapshot(snapshotService.createSnapshot(projectReference)); + } + + private void openNode(final EditorProjectNode node) { + fileBufferLoader.load(node.path()) + .ifPresentOrElse(this::openFile, () -> showUnsupportedFileModal(node.path())); + } + + private void openFile(final EditorOpenFileBuffer fileBuffer) { + openFileSession.open(fileBuffer); + renderSession(); + } + + private void renderSession() { + final var activeFile = openFileSession.activeFile(); + tabStrip.showOpenFiles( + openFileSession.openFiles(), + activeFile.map(EditorOpenFileBuffer::path).orElse(null)); + if (activeFile.isEmpty()) { + showEditorPlaceholder(); + statusBar.showPlaceholder(); + return; + } + + final var fileBuffer = activeFile.orElseThrow(); + codeArea.replaceText(fileBuffer.content()); + statusBar.showFile(fileBuffer, breadcrumb(fileBuffer.path())); + } + + private String breadcrumb(final java.nio.file.Path path) { + final var relativePath = projectReference.rootPath() + .toAbsolutePath() + .normalize() + .relativize(path.toAbsolutePath().normalize()) + .toString() + .replace('\\', '/'); + return projectReference.name() + " > " + relativePath.replace("/", " > "); + } + + private void showUnsupportedFileModal(final java.nio.file.Path path) { + final var alert = new Alert(Alert.AlertType.INFORMATION); + if (root.getScene() != null) { + alert.initOwner(root.getScene().getWindow()); + } + alert.setHeaderText(null); + alert.setTitle(p.studio.Container.i18n().text(I18n.CODE_EDITOR_UNSUPPORTED_FILE_TITLE)); + alert.setContentText(p.studio.Container.i18n().format(I18n.CODE_EDITOR_UNSUPPORTED_FILE_MESSAGE, path.getFileName())); + alert.showAndWait(); + } + + private void showEditorPlaceholder() { + codeArea.replaceText(""" + // Read-only first wave + // Open a supported text file from the project navigator. + """); + } + private VBox buildLayout() { final var content = new SplitPane(buildLeftColumn(), buildCenterColumn()); content.setDividerPositions(0.27); diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index ca56dc3e..c201578f 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -78,6 +78,8 @@ codeEditor.status.lineSeparator=LF codeEditor.status.indentation=Spaces: 4 codeEditor.status.language=Text codeEditor.status.readOnly=Read-only +codeEditor.unsupportedFile.title=Unsupported file +codeEditor.unsupportedFile.message=This file is not supported in this wave: {0} workspace.shipper=Shipper workspace.shipper.logs=Logs workspace.shipper.button.run=Build diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 65d20982..9a231ed8 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -391,6 +391,26 @@ -fx-min-height: 320px; } +.editor-workspace-tree { + -fx-background-color: transparent; + -fx-border-color: transparent; +} + +.editor-workspace-tree .tree-cell { + -fx-background-color: transparent; + -fx-text-fill: #d6e0ea; + -fx-font-size: 12px; +} + +.editor-workspace-tree .tree-cell:selected { + -fx-background-color: #24415e; + -fx-text-fill: #ffffff; +} + +.editor-workspace-tree-cell-tagged { + -fx-font-weight: bold; +} + .editor-workspace-outline-panel { -fx-min-height: 150px; } @@ -423,6 +443,20 @@ -fx-background-color: #171c22; } +.editor-workspace-tab-button { + -fx-padding: 8 12 8 12; +} + +.editor-workspace-tab-button-active { + -fx-background-color: #24415e; + -fx-border-color: #4d88bc; + -fx-text-fill: #ffffff; +} + +.editor-workspace-tab-overflow { + -fx-padding: 8 12 8 12; +} + .editor-workspace-code-area .content { -fx-background-color: #171c22; } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorFileBufferLoaderTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorFileBufferLoaderTest.java new file mode 100644 index 00000000..364d9385 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorFileBufferLoaderTest.java @@ -0,0 +1,37 @@ +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()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java new file mode 100644 index 00000000..cade85be --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorOpenFileSessionTest.java @@ -0,0 +1,48 @@ +package p.studio.workspaces.editor; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class EditorOpenFileSessionTest { + + @Test + void openAddsNewFileAndMarksItActive() { + final var session = new EditorOpenFileSession(); + final var file = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "fn main(): void\n", "LF"); + + session.open(file); + + assertEquals(1, session.openFiles().size()); + assertEquals(file.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path()); + } + + @Test + void openDoesNotDuplicateExistingTab() { + final var session = new EditorOpenFileSession(); + final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "a", "LF"); + final var second = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "b", "LF"); + + session.open(first); + session.open(second); + + assertEquals(1, session.openFiles().size()); + assertEquals(first.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path()); + assertEquals("b", session.activeFile().orElseThrow().content()); + } + + @Test + void activateSwitchesTheActiveTabWithinTheCurrentSession() { + final var session = new EditorOpenFileSession(); + final var first = new EditorOpenFileBuffer(Path.of("src/main.pbs"), "main.pbs", "a", "LF"); + final var second = new EditorOpenFileBuffer(Path.of("src/other.pbs"), "other.pbs", "b", "LF"); + + session.open(first); + session.open(second); + session.activate(first.path()); + + assertEquals(first.path().toAbsolutePath().normalize(), session.activeFile().orElseThrow().path()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorProjectSnapshotServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorProjectSnapshotServiceTest.java new file mode 100644 index 00000000..b1c27451 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorProjectSnapshotServiceTest.java @@ -0,0 +1,36 @@ +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()); + } +}