implements PLN-0014

This commit is contained in:
bQUARKz 2026-03-30 23:59:19 +01:00
parent c7ca603731
commit c6286f13a5
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
18 changed files with 779 additions and 50 deletions

View File

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

View File

@ -2,9 +2,9 @@
id: PLN-0014 id: PLN-0014
ticket: studio-code-editor-workspace-foundations ticket: studio-code-editor-workspace-foundations
title: Implement the Project Navigator snapshot and read-only file opening flow title: Implement the Project Navigator snapshot and read-only file opening flow
status: open status: done
created: 2026-03-30 created: 2026-03-30
completed: completed: 2026-03-30
tags: tags:
- studio - studio
- editor - editor
@ -130,11 +130,11 @@ When a navigator selection targets an unsupported file type, show a simple modal
## Acceptance Criteria ## Acceptance Criteria
- [ ] The editor has a structural navigator snapshot that covers the whole project and tags frontend-relevant roots. - [x] 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. - [x] The navigator performs initial refresh and supports manual refresh without watcher dependency.
- [ ] Supported files open into read-only tabs with responsive/overflow behavior preserved. - [x] Supported files open into read-only tabs with responsive/overflow behavior preserved.
- [ ] Unsupported files trigger a simple modal instead of a partial preview. - [x] Unsupported files trigger a simple modal instead of a partial preview.
- [ ] Opened-file content and visual tab state remain session-local only. - [x] Opened-file content and visual tab state remain session-local only.
## Dependencies ## Dependencies

View File

@ -87,6 +87,8 @@ public enum I18n {
CODE_EDITOR_STATUS_INDENTATION("codeEditor.status.indentation"), CODE_EDITOR_STATUS_INDENTATION("codeEditor.status.indentation"),
CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"), CODE_EDITOR_STATUS_LANGUAGE("codeEditor.status.language"),
CODE_EDITOR_STATUS_READ_ONLY("codeEditor.status.readOnly"), 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("workspace.shipper"),
WORKSPACE_SHIPPER_LOGS("workspace.shipper.logs"), WORKSPACE_SHIPPER_LOGS("workspace.shipper.logs"),

View File

@ -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<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

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

View File

@ -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<EditorOpenFileBuffer> 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<EditorOpenFileBuffer> openFiles() {
return List.copyOf(openFiles);
}
public Optional<EditorOpenFileBuffer> activeFile() {
if (activePath == null) {
return Optional.empty();
}
return find(activePath);
}
public boolean isEmpty() {
return openFiles.isEmpty();
}
private Optional<EditorOpenFileBuffer> 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();
}
}

View File

@ -3,21 +3,41 @@ package p.studio.workspaces.editor;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Label; 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.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
import javafx.scene.layout.VBox; import javafx.scene.layout.StackPane;
import p.studio.Container; import p.studio.Container;
import p.studio.utilities.i18n.I18n; 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 { public final class EditorProjectNavigatorPanel extends BorderPane {
private final Button refreshButton = new Button();
private final TreeView<EditorProjectNode> treeView = new TreeView<>();
private final Label emptyState = emptyLabel();
private final StackPane content = new StackPane();
private Runnable refreshAction = () -> { };
private Consumer<EditorProjectNode> fileSelectionAction = node -> { };
public EditorProjectNavigatorPanel() { public EditorProjectNavigatorPanel() {
getStyleClass().addAll("editor-workspace-panel", "editor-workspace-navigator-panel"); getStyleClass().addAll("editor-workspace-panel", "editor-workspace-navigator-panel");
setPadding(new Insets(14, 16, 14, 16)); setPadding(new Insets(14, 16, 14, 16));
setTop(buildHeader()); setTop(buildHeader());
setCenter(buildPlaceholder()); configureTreeView();
content.getStyleClass().add("editor-workspace-panel-content");
content.getChildren().addAll(treeView, emptyState);
setCenter(content);
showEmptyState(true);
} }
private HBox buildHeader() { 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.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_TITLE));
title.getStyleClass().add("editor-workspace-panel-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.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_REFRESH));
refreshButton.setDisable(true);
refreshButton.setFocusTraversable(false); refreshButton.setFocusTraversable(false);
refreshButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); refreshButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
refreshButton.setOnAction(event -> refreshAction.run());
final var spacer = new Region(); final var spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS); HBox.setHgrow(spacer, Priority.ALWAYS);
@ -39,19 +58,122 @@ public final class EditorProjectNavigatorPanel extends BorderPane {
return header; 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<EditorProjectNode> 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<EditorProjectNode> buildTreeItem(
final EditorProjectNode node,
final Set<Path> 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<Path> captureExpandedPaths(final TreeItem<EditorProjectNode> item) {
final var expandedPaths = new HashSet<Path>();
if (item == null) {
return expandedPaths;
}
collectExpandedPaths(item, expandedPaths);
return expandedPaths;
}
private void collectExpandedPaths(final TreeItem<EditorProjectNode> item, final Set<Path> expandedPaths) {
final var value = item.getValue();
if (value != null && item.isExpanded()) {
expandedPaths.add(value.path());
}
item.getChildren().forEach(child -> collectExpandedPaths(child, expandedPaths));
}
private Optional<Path> selectedPath() {
return Optional.ofNullable(treeView.getSelectionModel().getSelectedItem())
.map(TreeItem::getValue)
.map(EditorProjectNode::path);
}
private void restoreSelection(final TreeItem<EditorProjectNode> rootItem, final Optional<Path> selectedPath) {
treeView.getSelectionModel().clearSelection();
if (selectedPath.isEmpty()) {
return;
}
findTreeItem(rootItem, selectedPath.orElseThrow())
.ifPresent(item -> treeView.getSelectionModel().select(item));
}
private Optional<TreeItem<EditorProjectNode>> findTreeItem(final TreeItem<EditorProjectNode> 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(); final var placeholder = new Label();
placeholder.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_PLACEHOLDER)); placeholder.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_NAVIGATOR_PLACEHOLDER));
placeholder.getStyleClass().add("editor-workspace-placeholder"); placeholder.getStyleClass().add("editor-workspace-placeholder");
placeholder.setWrapText(true); placeholder.setWrapText(true);
return placeholder;
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;
} }
} }

View File

@ -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<EditorProjectNode> children) {
public EditorProjectNode {
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
displayName = Objects.requireNonNull(displayName, "displayName");
children = List.copyOf(children);
}
}

View File

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

View File

@ -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<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

@ -9,34 +9,83 @@ import p.studio.Container;
import p.studio.utilities.i18n.I18n; import p.studio.utilities.i18n.I18n;
public final class EditorStatusBar extends HBox { 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() { public EditorStatusBar() {
setAlignment(Pos.CENTER_LEFT); setAlignment(Pos.CENTER_LEFT);
setSpacing(12); setSpacing(12);
getStyleClass().add("editor-workspace-status-bar"); 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"); breadcrumb.getStyleClass().add("editor-workspace-status-breadcrumb");
final var spacer = new Region(); final var spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS); HBox.setHgrow(spacer, Priority.ALWAYS);
styleChip(position);
styleChip(lineSeparator);
styleChip(indentation);
styleChip(language);
styleChip(readOnly);
getChildren().addAll( getChildren().addAll(
breadcrumb, breadcrumb,
spacer, spacer,
statusChip(I18n.CODE_EDITOR_STATUS_POSITION), position,
statusChip(I18n.CODE_EDITOR_STATUS_LINE_SEPARATOR), lineSeparator,
statusChip(I18n.CODE_EDITOR_STATUS_INDENTATION), indentation,
statusChip(I18n.CODE_EDITOR_STATUS_LANGUAGE), language,
statusChip(I18n.CODE_EDITOR_STATUS_READ_ONLY) readOnly
); );
showPlaceholder();
} }
private Label statusChip(final I18n key) { public void showFile(final EditorOpenFileBuffer fileBuffer, final String breadcrumbText) {
final var chip = new Label(); setText(breadcrumb, breadcrumbText);
chip.textProperty().bind(Container.i18n().bind(key)); bindDefault(position, I18n.CODE_EDITOR_STATUS_POSITION);
chip.getStyleClass().add("editor-workspace-status-chip"); setText(lineSeparator, fileBuffer.lineSeparator());
return chip; 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;
} }
} }

View File

@ -2,33 +2,126 @@ package p.studio.workspaces.editor;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.Button; 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.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
import p.studio.Container; import p.studio.Container;
import p.studio.utilities.i18n.I18n; 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 { 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<EditorOpenFileBuffer> openFiles = new ArrayList<>();
private Consumer<Path> tabSelectionAction = path -> { };
private Path activePath;
public EditorTabStrip() { public EditorTabStrip() {
setAlignment(Pos.CENTER_LEFT); setAlignment(Pos.CENTER_LEFT);
setSpacing(10); setSpacing(10);
getStyleClass().add("editor-workspace-tab-strip"); 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); HBox.setHgrow(spacer, Priority.ALWAYS);
final var overflowButton = new Button();
overflowButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_OVERFLOW)); overflowButton.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_TABS_OVERFLOW));
overflowButton.setDisable(true);
overflowButton.setFocusTraversable(false); 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<Path> tabSelectionAction) {
this.tabSelectionAction = Objects.requireNonNull(tabSelectionAction, "tabSelectionAction");
}
public void showOpenFiles(final List<EditorOpenFileBuffer> 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<MenuItem> buildOverflowItems(final int startIndex, final int endIndex) {
final var items = new ArrayList<MenuItem>();
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));
} }
} }

View File

@ -1,5 +1,6 @@
package p.studio.workspaces.editor; package p.studio.workspaces.editor;
import javafx.scene.control.Alert;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.SplitPane; import javafx.scene.control.SplitPane;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
@ -20,6 +21,9 @@ public final class EditorWorkspace extends Workspace {
private final EditorHelperPanel helperPanel = new EditorHelperPanel(); private final EditorHelperPanel helperPanel = new EditorHelperPanel();
private final EditorStatusBar statusBar = new EditorStatusBar(); private final EditorStatusBar statusBar = new EditorStatusBar();
private final EditorTabStrip tabStrip = new EditorTabStrip(); 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) { public EditorWorkspace(final ProjectReference projectReference) {
super(projectReference); super(projectReference);
@ -28,12 +32,16 @@ public final class EditorWorkspace extends Workspace {
codeArea.setEditable(false); codeArea.setEditable(false);
codeArea.setWrapText(false); codeArea.setWrapText(false);
codeArea.getStyleClass().add("editor-workspace-code-area"); codeArea.getStyleClass().add("editor-workspace-code-area");
codeArea.replaceText(""" showEditorPlaceholder();
// Read-only first wave navigatorPanel.setRefreshAction(this::refreshNavigator);
// Open-file wiring lands in the next plan. navigatorPanel.setFileSelectionAction(this::openNode);
"""); tabStrip.setTabSelectionAction(path -> {
openFileSession.activate(path);
renderSession();
});
root.setCenter(buildLayout()); root.setCenter(buildLayout());
statusBar.showPlaceholder();
} }
@Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; } @Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; }
@ -42,7 +50,7 @@ public final class EditorWorkspace extends Workspace {
@Override @Override
public void load() { public void load() {
refreshNavigator();
} }
@Override @Override
@ -52,6 +60,64 @@ public final class EditorWorkspace extends Workspace {
public CodeArea codeArea() { return codeArea; } 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() { private VBox buildLayout() {
final var content = new SplitPane(buildLeftColumn(), buildCenterColumn()); final var content = new SplitPane(buildLeftColumn(), buildCenterColumn());
content.setDividerPositions(0.27); content.setDividerPositions(0.27);

View File

@ -78,6 +78,8 @@ codeEditor.status.lineSeparator=LF
codeEditor.status.indentation=Spaces: 4 codeEditor.status.indentation=Spaces: 4
codeEditor.status.language=Text codeEditor.status.language=Text
codeEditor.status.readOnly=Read-only 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=Shipper
workspace.shipper.logs=Logs workspace.shipper.logs=Logs
workspace.shipper.button.run=Build workspace.shipper.button.run=Build

View File

@ -391,6 +391,26 @@
-fx-min-height: 320px; -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 { .editor-workspace-outline-panel {
-fx-min-height: 150px; -fx-min-height: 150px;
} }
@ -423,6 +443,20 @@
-fx-background-color: #171c22; -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 { .editor-workspace-code-area .content {
-fx-background-color: #171c22; -fx-background-color: #171c22;
} }

View File

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

View File

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

View File

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